From 403f884e70e227bf67cfef1679981877f601274c Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 01:10:33 +0300 Subject: [PATCH 01/16] feat(workspace-details): add changeWorkspaceEditor action creator Creates new DevWorkspaceTemplate, patches workspace annotations and spec.contributions, then deletes the old template. Reuses createDevWorkspaceTemplate and patchWorkspace from existing code. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../backend-client/devWorkspaceTemplateApi.ts | 11 ++ .../__tests__/changeWorkspaceEditor.spec.ts | 153 ++++++++++++++++++ .../actionCreators/changeWorkspaceEditor.ts | 143 ++++++++++++++++ 3 files changed, 307 insertions(+) create mode 100644 packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts create mode 100644 packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts diff --git a/packages/dashboard-frontend/src/services/backend-client/devWorkspaceTemplateApi.ts b/packages/dashboard-frontend/src/services/backend-client/devWorkspaceTemplateApi.ts index dede5c27ad..c368629966 100644 --- a/packages/dashboard-frontend/src/services/backend-client/devWorkspaceTemplateApi.ts +++ b/packages/dashboard-frontend/src/services/backend-client/devWorkspaceTemplateApi.ts @@ -76,3 +76,14 @@ export async function patchTemplate( ); } } + +export async function deleteTemplate(namespace: string, name: string): Promise { + const url = `${dashboardBackendPrefix}/namespace/${namespace}/devworkspacetemplates/${name}`; + try { + await AxiosWrapper.createToRetryMissedBearerTokenError().delete(url); + } catch (e) { + throw new Error( + `Failed to delete devWorkspaceTemplate '${name}'. ${common.helpers.errors.getMessage(e)}`, + ); + } +} diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts new file mode 100644 index 0000000000..32ec78bc93 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import devfileApi from '@/services/devfileApi'; +import { constructWorkspace } from '@/services/workspace-adapter'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; +import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +import { changeWorkspaceEditor } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; + +jest.mock('@/store/SanityCheck', () => ({ + ...jest.requireActual('@/store/SanityCheck'), + verifyAuthorized: jest.fn().mockResolvedValue(undefined), +})); +jest.mock('@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'); +jest.mock('@/services/backend-client/devWorkspaceApi'); +jest.mock('@/services/backend-client/devWorkspaceTemplateApi'); + +const wsAnnotations = { + 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest', + 'che.eclipse.org/devfile-source': + 'url:\n location: https://example.com\nfactory:\n params: >-\n che-editor=che-incubator/che-code/latest&storageType=per-user\n', +}; + +function buildWorkspace() { + const dw = new DevWorkspaceBuilder() + .withMetadata({ + name: 'empty-ido0', + namespace: 'test-ns', + uid: 'test-uid', + annotations: wsAnnotations, + }) + .withContributions([{ name: 'editor', kubernetes: { name: 'che-code-empty-ido0' } }]) + .build(); + return constructWorkspace(dw); +} + +describe('changeWorkspaceEditor', () => { + const intellijDevfile = { + schemaVersion: '2.3.0', + metadata: { + name: 'che-idea-server', + attributes: { publisher: 'che-incubator', version: 'latest' }, + }, + components: [ + { + name: 'editor-injector', + container: { image: 'quay.io/che-incubator/che-idea-dev-server:latest' }, + }, + ], + }; + + let mockCreateDevWorkspaceTemplate: jest.Mock; + let mockPatchWorkspace: jest.Mock; + let mockDeleteTemplate: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + + mockCreateDevWorkspaceTemplate = jest.fn().mockResolvedValue({}); + (getDevWorkspaceClient as jest.Mock).mockReturnValue({ + createDevWorkspaceTemplate: mockCreateDevWorkspaceTemplate, + }); + + const DwApi = jest.requireMock('@/services/backend-client/devWorkspaceApi'); + mockPatchWorkspace = DwApi.patchWorkspace = jest + .fn() + .mockResolvedValue({ devWorkspace: { metadata: { name: 'empty-ido0' } } }); + + const DwtApi = jest.requireMock('@/services/backend-client/devWorkspaceTemplateApi'); + mockDeleteTemplate = DwtApi.deleteTemplate = jest.fn().mockResolvedValue({}); + }); + + it('creates a new DevWorkspaceTemplate for the new editor', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockCreateDevWorkspaceTemplate).toHaveBeenCalledTimes(1); + const callArgs = mockCreateDevWorkspaceTemplate.mock.calls[0] as unknown[]; + expect(callArgs[0]).toBe('test-ns'); + expect(callArgs[1]).toMatchObject({ metadata: { name: 'empty-ido0' } }); + expect(callArgs[2]).toMatchObject({ + metadata: { + name: 'che-idea-server-empty-ido0', + annotations: { + 'che.eclipse.org/plugin-registry-url': expect.stringContaining('che-idea-server/latest'), + }, + }, + }); + }); + + it('patches che-editor annotation and spec.contributions on the workspace', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockPatchWorkspace).toHaveBeenCalledWith( + 'test-ns', + 'empty-ido0', + expect.arrayContaining([ + expect.objectContaining({ path: '/metadata/annotations' }), + expect.objectContaining({ + path: '/spec/contributions/0/kubernetes/name', + value: 'che-idea-server-empty-ido0', + }), + ]), + ); + + const patches = mockPatchWorkspace.mock.calls[0][2] as Array<{ + path: string; + value: Record; + }>; + const annotationsPatch = patches.find(p => p.path === '/metadata/annotations'); + expect(annotationsPatch?.value['che.eclipse.org/che-editor']).toBe( + 'che-incubator/che-idea-server/latest', + ); + expect(annotationsPatch?.value['che.eclipse.org/devfile-source']).toContain( + 'che-editor=che-incubator/che-idea-server/latest', + ); + }); + + it('deletes the old template', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockDeleteTemplate).toHaveBeenCalledWith('test-ns', 'che-code-empty-ido0'); + }); + + it('throws when editor is not found in cmEditors', async () => { + const store = new MockStoreBuilder().build(); // no cmEditors + const workspace = buildWorkspace(); + await expect( + store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/unknown-editor/latest')), + ).rejects.toThrow('not found'); + }); +}); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts new file mode 100644 index 0000000000..5db43b4ad0 --- /dev/null +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { V1alpha2DevWorkspaceTemplateSpec } from '@devfile/api'; +import common, { ApplicationId } from '@eclipse-che/common'; +import cloneDeep from 'lodash/cloneDeep'; + +import * as DwApi from '@/services/backend-client/devWorkspaceApi'; +import * as DwtApi from '@/services/backend-client/devWorkspaceTemplateApi'; +import devfileApi from '@/services/devfileApi'; +import { DEVWORKSPACE_CHE_EDITOR } from '@/services/devfileApi/devWorkspace/metadata'; +import { Workspace } from '@/services/workspace-adapter'; +import { + COMPONENT_UPDATE_POLICY, + DEVWORKSPACE_DEVFILE_SOURCE, + REGISTRY_URL, +} from '@/services/workspace-client/devworkspace/devWorkspaceClient'; +import { AppThunk } from '@/store'; +import { selectApplications } from '@/store/ClusterInfo/selectors'; +import { EDITOR_DEVFILE_API_QUERY } from '@/store/DevfileRegistries/const'; +import { verifyAuthorized } from '@/store/SanityCheck'; +import { selectServerConfigState } from '@/store/ServerConfig/selectors'; +import { getDevWorkspaceClient } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers'; +import { getEditorName } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/helpers/updateEditor'; +import { + devWorkspacesErrorAction, + devWorkspacesRequestAction, + devWorkspacesUpdateAction, +} from '@/store/Workspaces/devWorkspaces/actions/actions'; + +export const changeWorkspaceEditor = + (workspace: Workspace, newEditorId: string): AppThunk => + async (dispatch, getState) => { + const dw = workspace.ref; + const namespace = dw.metadata.namespace; + const workspaceName = dw.metadata.name; + const state = getState(); + + try { + await verifyAuthorized(dispatch, getState); + dispatch(devWorkspacesRequestAction()); + + // Resolve editor devfile from store (same source checkForTemplatesUpdate uses at start time) + const editors = state.dwPlugins.cmEditors || []; + const editorDevfile = editors.find( + e => + `${e.metadata.attributes.publisher}/${e.metadata.name}/${e.metadata.attributes.version}` === + newEditorId, + ); + if (!editorDevfile) { + throw new Error(`Editor "${newEditorId}" not found in the plugin registry`); + } + + // Build template spec — same logic as checkForTemplatesUpdate uses internally + const spec: Partial = {}; + for (const key in editorDevfile) { + if (key === 'schemaVersion' || key === 'metadata') { + continue; + } + if (key === 'components') { + const components = cloneDeep(editorDevfile.components ?? []); + components.forEach(c => { + if (c.container && !c.container.sourceMapping) { + c.container.sourceMapping = '/projects'; + } + }); + spec.components = components as V1alpha2DevWorkspaceTemplateSpec['components']; + } else { + (spec as Record)[key] = ( + editorDevfile as unknown as Record + )[key]; + } + } + + // Step 1: create new template (reuse createDevWorkspaceTemplate — adds ownerRef + env vars) + const newEditorName = newEditorId.split('/')[1]; + const newTemplateName = `${newEditorName}-${workspaceName}`; + + const newTemplate: devfileApi.DevWorkspaceTemplate = { + apiVersion: 'workspace.devfile.io/v1alpha2', + kind: 'DevWorkspaceTemplate', + metadata: { + name: newTemplateName, + namespace, + annotations: { + [COMPONENT_UPDATE_POLICY]: 'managed', + [REGISTRY_URL]: `${EDITOR_DEVFILE_API_QUERY}${newEditorId}`, + }, + }, + spec, + }; + + const serverConfig = selectServerConfigState(state).config; + const clusterConsole = selectApplications(state).find( + app => app.id === ApplicationId.CLUSTER_CONSOLE, + ); + await getDevWorkspaceClient().createDevWorkspaceTemplate( + namespace, + dw, + newTemplate, + serverConfig?.pluginRegistryURL, + serverConfig?.pluginRegistryInternalURL, + serverConfig?.pluginRegistry?.openVSXURL, + clusterConsole, + ); + + // Step 2: patch workspace — annotations + spec.contributions + const annotations = { ...(dw.metadata.annotations ?? {}) }; + annotations[DEVWORKSPACE_CHE_EDITOR] = newEditorId; + const devfileSource = annotations[DEVWORKSPACE_DEVFILE_SOURCE] ?? ''; + annotations[DEVWORKSPACE_DEVFILE_SOURCE] = devfileSource.replace( + /che-editor=[^&\n]+/, + `che-editor=${newEditorId}`, + ); + + const { devWorkspace: updatedDw } = await DwApi.patchWorkspace(namespace, workspaceName, [ + { op: 'replace', path: '/metadata/annotations', value: annotations }, + { op: 'replace', path: '/spec/contributions/0/kubernetes/name', value: newTemplateName }, + ]); + dispatch(devWorkspacesUpdateAction(updatedDw)); + + // Step 3: delete old template (ownerRef also GC-s it on workspace delete — explicit for cleanliness) + const oldTemplateName = getEditorName(dw); + if (oldTemplateName && oldTemplateName !== newTemplateName) { + await DwtApi.deleteTemplate(namespace, oldTemplateName); + } + } catch (e) { + const errorMessage = + `Failed to change editor for workspace ${workspaceName}, reason: ` + + common.helpers.errors.getMessage(e); + dispatch(devWorkspacesErrorAction(errorMessage)); + throw e; + } + }; From a82a3a49c9190c0b4680d6cd8f71379375f91f97 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 01:34:08 +0300 Subject: [PATCH 02/16] feat(editor): add grouping and annotation helpers Assisted-by: Claude Haiku 4.5 Signed-off-by: Oleksii Orel --- .../services/helpers/__tests__/editor.spec.ts | 120 ++++++++++++++++++ .../src/services/helpers/editor.ts | 42 ++++++ 2 files changed, 162 insertions(+) create mode 100644 packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts diff --git a/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts new file mode 100644 index 0000000000..bd291a657e --- /dev/null +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { + getCurrentEditorId, + getCurrentEditorLabel, + groupEditorsByName, +} from '@/services/helpers/editor'; +import { che } from '@/services/models'; +import { constructWorkspace } from '@/services/workspace-adapter'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; + +function makePlugin( + publisher: string, + name: string, + version: string, + displayName: string, +): che.Plugin { + return { + id: `${publisher}/${name}/${version}`, + name, + publisher, + displayName, + type: 'Che Editor', + version, + description: `${displayName} description`, + icon: '', + iconMediatype: 'image/svg+xml', + links: { devfile: '' }, + }; +} + +describe('groupEditorsByName', () => { + it('groups plugins with the same publisher/name together', () => { + const plugins = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-code', 'insiders', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), + ]; + const groups = groupEditorsByName(plugins); + expect(groups).toHaveLength(2); + expect(groups[0].key).toBe('che-incubator/che-code'); + expect(groups[0].displayName).toBe('VS Code - Open Source'); + expect(groups[0].versions).toHaveLength(2); + expect(groups[1].key).toBe('che-incubator/che-idea-server'); + expect(groups[1].versions).toHaveLength(1); + }); + + it('returns an empty array for empty input', () => { + expect(groupEditorsByName([])).toEqual([]); + }); + + it('preserves insertion order of groups', () => { + const plugins = [ + makePlugin('che-incubator', 'che-idea-server', 'latest', 'IntelliJ IDEA'), + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code'), + ]; + const groups = groupEditorsByName(plugins); + expect(groups[0].key).toBe('che-incubator/che-idea-server'); + expect(groups[1].key).toBe('che-incubator/che-code'); + }); +}); + +describe('getCurrentEditorId', () => { + it('returns the annotation value when present', () => { + const dw = new DevWorkspaceBuilder() + .withMetadata({ + annotations: { 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest' }, + }) + .build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorId(workspace)).toBe('che-incubator/che-code/latest'); + }); + + it('returns undefined when annotation is absent', () => { + const dw = new DevWorkspaceBuilder().build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorId(workspace)).toBeUndefined(); + }); +}); + +describe('getCurrentEditorLabel', () => { + const editors = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), + ]; + + it('returns "displayName · version" when the editor is found in the list', () => { + const dw = new DevWorkspaceBuilder() + .withMetadata({ + annotations: { 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest' }, + }) + .build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorLabel(workspace, editors)).toBe('VS Code - Open Source · latest'); + }); + + it('returns the raw id when the editor is not in the list', () => { + const dw = new DevWorkspaceBuilder() + .withMetadata({ annotations: { 'che.eclipse.org/che-editor': 'unknown/editor/next' } }) + .build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorLabel(workspace, editors)).toBe('unknown/editor/next'); + }); + + it('returns "Default" when annotation is absent', () => { + const dw = new DevWorkspaceBuilder().build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorLabel(workspace, editors)).toBe('Default'); + }); +}); diff --git a/packages/dashboard-frontend/src/services/helpers/editor.ts b/packages/dashboard-frontend/src/services/helpers/editor.ts index 87960fe26e..9490059fe1 100644 --- a/packages/dashboard-frontend/src/services/helpers/editor.ts +++ b/packages/dashboard-frontend/src/services/helpers/editor.ts @@ -13,6 +13,9 @@ import { dump } from 'js-yaml'; import devfileApi from '@/services/devfileApi'; +import { DEVWORKSPACE_CHE_EDITOR } from '@/services/devfileApi/devWorkspace/metadata'; +import { che } from '@/services/models'; +import { Workspace } from '@/services/workspace-adapter'; const sortOrder: Array = [ 'schemaVersion', @@ -58,3 +61,42 @@ export default function stringify(obj: devfileApi.Devfile | devfileApi.DevWorksp } return dump(obj, { lineWidth, sortKeys }); } + +export type EditorGroup = { + key: string; + displayName: string; + icon: string; + iconMediatype: string; + versions: che.Plugin[]; +}; + +export function groupEditorsByName(editors: che.Plugin[]): EditorGroup[] { + const map = new Map(); + for (const editor of editors) { + const key = `${editor.publisher}/${editor.name}`; + if (!map.has(key)) { + map.set(key, { + key, + displayName: editor.displayName || editor.name, + icon: editor.icon || '', + iconMediatype: editor.iconMediatype || '', + versions: [], + }); + } + map.get(key)!.versions.push(editor); + } + return Array.from(map.values()); +} + +export function getCurrentEditorId(workspace: Workspace): string | undefined { + return workspace.ref.metadata?.annotations?.[DEVWORKSPACE_CHE_EDITOR]; +} + +export function getCurrentEditorLabel(workspace: Workspace, editors: che.Plugin[]): string { + const id = getCurrentEditorId(workspace); + if (!id) { + return 'Default'; + } + const found = editors.find(e => e.id === id); + return found ? `${found.displayName || found.name} · ${found.version}` : id; +} From 7f4bc01456516e6dafaeec30fed1893f2acd08ea Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 01:44:55 +0300 Subject: [PATCH 03/16] feat(workspace-details): add EditorSelectorModal Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../Editor/SelectorModal.module.css | 21 ++ .../OverviewTab/Editor/SelectorModal.tsx | 230 ++++++++++++++++++ .../Editor/__mocks__/SelectorModal.tsx | 33 +++ .../Editor/__tests__/SelectorModal.spec.tsx | 158 ++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/SelectorModal.tsx create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css new file mode 100644 index 0000000000..6bb96d0f36 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +.radioLabel { + display: flex; + gap: 8px; + align-items: center; +} + +.versionLabel { + cursor: default; +} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx new file mode 100644 index 0000000000..a5dfd555cc --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { + Button, + Content, + ContentVariants, + Dropdown, + DropdownItem, + DropdownList, + Label, + MenuToggle, + MenuToggleElement, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalVariant, + Radio, +} from '@patternfly/react-core'; +import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; +import React from 'react'; + +import styles from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css'; +import { EditorGroup, groupEditorsByName } from '@/services/helpers/editor'; +import { che } from '@/services/models'; + +export type Props = { + isOpen: boolean; + currentEditorId: string | undefined; + editors: che.Plugin[]; + onConfirm: (editorId: string) => void; + onClose: () => void; +}; + +type State = { + selectedGroupKey: string; + selectedVersion: string; + openDropdownId: string | null; +}; + +function resolveInitialState(currentEditorId: string | undefined, groups: EditorGroup[]): State { + if (!currentEditorId) { + const first = groups[0]; + return { + selectedGroupKey: first?.key ?? '', + selectedVersion: first?.versions[0]?.version ?? '', + openDropdownId: null, + }; + } + // currentEditorId is "publisher/name/version" + const parts = currentEditorId.split('/'); + const version = parts[parts.length - 1]; + const key = parts.slice(0, -1).join('/'); + return { selectedGroupKey: key, selectedVersion: version, openDropdownId: null }; +} + +export class EditorSelectorModal extends React.PureComponent { + constructor(props: Props) { + super(props); + const groups = groupEditorsByName(props.editors); + this.state = resolveInitialState(props.currentEditorId, groups); + } + + public componentDidUpdate(prevProps: Props): void { + if ( + prevProps.currentEditorId !== this.props.currentEditorId || + prevProps.editors !== this.props.editors + ) { + const groups = groupEditorsByName(this.props.editors); + this.setState(resolveInitialState(this.props.currentEditorId, groups)); + } + } + + private get selectedEditorId(): string { + return `${this.state.selectedGroupKey}/${this.state.selectedVersion}`; + } + + private get hasChanged(): boolean { + return this.selectedEditorId !== (this.props.currentEditorId ?? ''); + } + + private handleSelectGroup(group: EditorGroup): void { + this.setState({ + selectedGroupKey: group.key, + selectedVersion: group.versions[0].version, + openDropdownId: null, + }); + } + + private handleVersionSelect( + event: React.MouseEvent | React.KeyboardEvent, + groupKey: string, + version: string, + ): void { + event.stopPropagation(); + event.preventDefault(); + this.setState({ selectedVersion: version, openDropdownId: null, selectedGroupKey: groupKey }); + } + + private buildVersionDropdown(group: EditorGroup): React.ReactElement | null { + if (group.versions.length <= 1) { + return null; + } + const { openDropdownId, selectedVersion, selectedGroupKey } = this.state; + const isOpen = openDropdownId === group.key; + const activeVersion = + selectedGroupKey === group.key ? selectedVersion : group.versions[0].version; + + return ( + ) => ( + { + e.stopPropagation(); + this.setState({ openDropdownId: isOpen ? null : group.key }); + }} + isExpanded={isOpen} + aria-label={`${group.displayName} version options`} + icon={} + /> + )} + isOpen={isOpen} + onOpenChange={open => this.setState({ openDropdownId: open ? group.key : null })} + popperProps={{ appendTo: 'inline', position: 'right' }} + > + + {isOpen + ? group.versions.map(v => ( + this.handleVersionSelect(event, group.key, v.version)} + aria-checked={v.version === activeVersion} + icon={v.version === activeVersion ? : undefined} + > + {v.version} + + )) + : null} + + + ); + } + + public render(): React.ReactNode { + const { isOpen, editors, onConfirm, onClose } = this.props; + const { selectedGroupKey, selectedVersion } = this.state; + + const groups = groupEditorsByName(editors); + + return ( + + + + + {groups.length === 0 ? ( + No editors are available. + ) : ( + <> + Select editor + {groups.map(group => { + const isGroupSelected = selectedGroupKey === group.key; + const activeVersion = isGroupSelected + ? selectedVersion + : group.versions[0].version; + const versionDropdown = this.buildVersionDropdown(group); + + const radioLabel = ( + + {group.displayName} + {isGroupSelected && ( + + )} + {versionDropdown} + + ); + + return ( + + this.handleSelectGroup(group)} + /> + + ); + })} + + )} + + + + + + + + ); + } +} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/SelectorModal.tsx new file mode 100644 index 0000000000..7bb67d3bd8 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/SelectorModal.tsx @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { Props } from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'; + +export class EditorSelectorModal extends React.PureComponent { + render() { + const { isOpen, onConfirm, onClose, editors } = this.props; + if (!isOpen) { + return null; + } + const firstId = editors[0]?.id ?? ''; + return ( +
+ + +
+ ); + } +} + +export default EditorSelectorModal; diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx new file mode 100644 index 0000000000..4db5843aca --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EditorSelectorModal } from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { che } from '@/services/models'; + +const { renderComponent } = getComponentRenderer(getComponent); + +function makePlugin( + publisher: string, + name: string, + version: string, + displayName: string, +): che.Plugin { + return { + id: `${publisher}/${name}/${version}`, + name, + publisher, + displayName, + type: 'Che Editor', + version, + icon: '', + iconMediatype: 'image/svg+xml', + links: { devfile: '' }, + }; +} + +const editors: che.Plugin[] = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-code', 'insiders', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), +]; + +const mockOnConfirm = jest.fn(); +const mockOnClose = jest.fn(); + +function getComponent(isOpen: boolean, currentEditorId: string | undefined): React.ReactElement { + return ( + + ); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('EditorSelectorModal', () => { + it('renders nothing when isOpen is false', () => { + renderComponent(false, undefined); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('renders a dialog when isOpen is true', () => { + renderComponent(true, undefined); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByText('Change Editor')).toBeInTheDocument(); + }); + + it('renders one radio per editor group', () => { + renderComponent(true, undefined); + const radios = screen.getAllByRole('radio'); + // 2 groups: che-code, che-idea-server + expect(radios).toHaveLength(2); + }); + + it('pre-selects the radio that matches currentEditorId', () => { + renderComponent(true, 'che-incubator/che-idea-server/latest'); + const radio = screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i }); + expect(radio).toBeChecked(); + }); + + it('Save button is disabled when selection has not changed', () => { + renderComponent(true, 'che-incubator/che-code/latest'); + expect(screen.getByRole('button', { name: /Save/i })).toBeDisabled(); + }); + + it('Save button becomes enabled after selecting a different editor', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + await userEvent.click(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })); + expect(screen.getByRole('button', { name: /Save/i })).not.toBeDisabled(); + }); + + it('calls onConfirm with the selected editor id on Save', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + await userEvent.click(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })); + await userEvent.click(screen.getByRole('button', { name: /Save/i })); + expect(mockOnConfirm).toHaveBeenCalledWith('che-incubator/che-idea-server/latest'); + }); + + it('calls onClose on Cancel', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + await userEvent.click(screen.getByRole('button', { name: /Cancel/i })); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('shows a version label for each editor group', () => { + renderComponent(true, undefined); + // VS Code - Open Source has versions: latest, insiders + expect(screen.getByText('latest')).toBeInTheDocument(); + }); + + it('shows version dropdown trigger for editors with multiple versions', () => { + renderComponent(true, undefined); + // VS Code has 2 versions → kebab button present + expect( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ).toBeInTheDocument(); + }); + + it('does NOT show version dropdown for editors with a single version', () => { + renderComponent(true, undefined); + // IntelliJ has 1 version → no kebab + expect( + screen.queryByRole('button', { name: /JetBrains IntelliJ IDEA version options/i }), + ).not.toBeInTheDocument(); + }); + + it('updates selected version when a version is chosen from the dropdown', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + // open version dropdown + await userEvent.click( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ); + await userEvent.click(screen.getByRole('menuitem', { name: 'insiders' })); + // now version label shows insiders + expect(screen.getByText('insiders')).toBeInTheDocument(); + }); + + it('calls onConfirm with the correct version when a non-default version is selected then confirmed', async () => { + renderComponent(true, 'che-incubator/che-code/latest'); + // switch to insiders version + await userEvent.click( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ); + await userEvent.click(screen.getByRole('menuitem', { name: 'insiders' })); + await userEvent.click(screen.getByRole('button', { name: /Save/i })); + expect(mockOnConfirm).toHaveBeenCalledWith('che-incubator/che-code/insiders'); + }); +}); From e7bf22c07c53d628187611da453ae5dfdbea49c1 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 01:58:21 +0300 Subject: [PATCH 04/16] feat(workspace-details): add EditorFormGroup to Overview tab Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../OverviewTab/Editor/__mocks__/index.tsx | 33 +++++ .../Editor/__tests__/index.spec.tsx | 118 ++++++++++++++++++ .../OverviewTab/Editor/index.tsx | 99 +++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/index.tsx create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx create mode 100644 packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/index.tsx new file mode 100644 index 0000000000..244d337b1e --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__mocks__/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import React from 'react'; + +import { Workspace } from '@/services/workspace-adapter'; + +type MockProps = { + readonly: boolean; + workspace: Workspace; +}; + +export class EditorFormGroup extends React.PureComponent { + render() { + return ( +
+ Mock Editor Form Group + +
+ ); + } +} + +export default EditorFormGroup; diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx new file mode 100644 index 0000000000..291c7301fc --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { EditorFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/Editor'; +import getComponentRenderer, { screen } from '@/services/__mocks__/getComponentRenderer'; +import { che } from '@/services/models'; +import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; +import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; + +jest.mock('@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'); + +const { renderComponent } = getComponentRenderer(getComponent); + +function makePlugin( + publisher: string, + name: string, + version: string, + displayName: string, +): che.Plugin { + return { + id: `${publisher}/${name}/${version}`, + name, + publisher, + displayName, + type: 'Che Editor', + version, + icon: '', + iconMediatype: 'image/svg+xml', + links: { devfile: '' }, + }; +} + +const editors = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), +]; + +const mockChangeEditor = jest.fn().mockResolvedValue(undefined); + +function buildWorkspace(editorId?: string): Workspace { + const builder = new DevWorkspaceBuilder(); + if (editorId) { + builder.withMetadata({ annotations: { 'che.eclipse.org/che-editor': editorId } }); + } + return constructWorkspace(builder.build()); +} + +function getComponent(readonly: boolean, workspace: Workspace): React.ReactElement { + return ( + + ); +} + +beforeEach(() => jest.clearAllMocks()); + +describe('EditorFormGroup', () => { + it('shows "Default" when no editor annotation is set', () => { + renderComponent(false, buildWorkspace()); + expect(screen.getByText('Default')).toBeInTheDocument(); + }); + + it('shows the editor display name and version', () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.getByText('VS Code - Open Source · latest')).toBeInTheDocument(); + }); + + it('pencil button is disabled when readonly is true', () => { + renderComponent(true, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.getByRole('button', { name: /Change editor/i })).toBeDisabled(); + }); + + it('pencil button is enabled when readonly is false', () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.getByRole('button', { name: /Change editor/i })).not.toBeDisabled(); + }); + + it('opens the selector modal when pencil button is clicked', async () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + expect(screen.queryByTestId('mock-editor-selector-modal')).not.toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Change editor/i })); + expect(screen.getByTestId('mock-editor-selector-modal')).toBeInTheDocument(); + }); + + it('calls changeEditor with the selected editor id when confirm is clicked', async () => { + const workspace = buildWorkspace('che-incubator/che-code/latest'); + renderComponent(false, workspace); + await userEvent.click(screen.getByRole('button', { name: /Change editor/i })); + await userEvent.click(screen.getByRole('button', { name: /Confirm Editor/i })); + expect(mockChangeEditor).toHaveBeenCalledTimes(1); + expect(mockChangeEditor).toHaveBeenCalledWith(workspace, 'che-incubator/che-code/latest'); + }); + + it('closes the modal without calling changeEditor when Close Modal is clicked', async () => { + renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); + await userEvent.click(screen.getByRole('button', { name: /Change editor/i })); + await userEvent.click(screen.getByRole('button', { name: /Close Modal/i })); + expect(mockChangeEditor).not.toHaveBeenCalled(); + expect(screen.queryByTestId('mock-editor-selector-modal')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx new file mode 100644 index 0000000000..a375365e62 --- /dev/null +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2018-2025 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ + +import { Architecture } from '@eclipse-che/common'; +import { Button, FormGroup } from '@patternfly/react-core'; +import { PencilAltIcon } from '@patternfly/react-icons'; +import React from 'react'; +import { connect, ConnectedProps } from 'react-redux'; + +import { EditorSelectorModal } from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal'; +import overviewStyles from '@/pages/WorkspaceDetails/OverviewTab/index.module.css'; +import { getCurrentEditorId, getCurrentEditorLabel } from '@/services/helpers/editor'; +import { che } from '@/services/models'; +import { Workspace } from '@/services/workspace-adapter'; +import { RootState } from '@/store'; +import { selectCurrentArchitecture } from '@/store/ClusterConfig/selectors'; +import { selectEditors } from '@/store/Plugins/chePlugins/selectors'; +import { changeWorkspaceEditor } from '@/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor'; + +export type Props = MappedProps & { + readonly: boolean; + workspace: Workspace; +}; + +type State = { + isSelectorOpen: boolean; +}; + +export class EditorFormGroup extends React.PureComponent { + state: State = { isSelectorOpen: false }; + + private get filteredEditors(): che.Plugin[] { + const { editors, currentArchitecture } = this.props; + return editors.filter( + e => !currentArchitecture || !e.arch || e.arch.includes(currentArchitecture as Architecture), + ); + } + + private async handleConfirm(newEditorId: string): Promise { + const { workspace, changeEditor } = this.props; + this.setState({ isSelectorOpen: false }); + await changeEditor(workspace, newEditorId); + } + + public render(): React.ReactNode { + const { readonly, workspace } = this.props; + const { isSelectorOpen } = this.state; + const editors = this.filteredEditors; + const label = getCurrentEditorLabel(workspace, editors); + const currentEditorId = getCurrentEditorId(workspace); + + return ( + + + {label} + + + this.handleConfirm(editorId)} + onClose={() => this.setState({ isSelectorOpen: false })} + /> + + ); + } +} + +const mapStateToProps = (state: RootState) => ({ + editors: selectEditors(state), + currentArchitecture: selectCurrentArchitecture(state) as Architecture | undefined, +}); + +const mapDispatchToProps = { + changeEditor: changeWorkspaceEditor, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +type MappedProps = ConnectedProps; +export default connector(EditorFormGroup); From 19cb3b5cb34ba8874f1d2725e78a3a38db7e69a9 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 02:13:35 +0300 Subject: [PATCH 05/16] feat(workspace-details): wire EditorFormGroup into Overview tab Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../__tests__/__snapshots__/index.spec.tsx.snap | 16 ++++++++++++++++ .../OverviewTab/__tests__/index.spec.tsx | 7 +++++++ .../pages/WorkspaceDetails/OverviewTab/index.tsx | 2 ++ 3 files changed, 25 insertions(+) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/__snapshots__/index.spec.tsx.snap b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/__snapshots__/index.spec.tsx.snap index 23ff786fe5..1bd7ed5861 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/__snapshots__/index.spec.tsx.snap +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/__snapshots__/index.spec.tsx.snap @@ -46,6 +46,14 @@ exports[`OverviewTab With parent screenshot 1`] = ` StorageType: +
+ Mock Editor Form Group + +
Mock Projects Form
@@ -103,6 +111,14 @@ exports[`OverviewTab Without parent screenshot 1`] = ` StorageType: +
+ Mock Editor Form Group + +
Mock Projects Form
diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx index d3225bd859..2f510538ac 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/__tests__/index.spec.tsx @@ -21,6 +21,7 @@ import { constructWorkspace, Workspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; +jest.mock('@/pages/WorkspaceDetails/OverviewTab/Editor'); jest.mock('@/pages/WorkspaceDetails/OverviewTab/InfrastructureNamespace'); jest.mock('@/pages/WorkspaceDetails/OverviewTab/Projects'); jest.mock('@/pages/WorkspaceDetails/OverviewTab/StorageType'); @@ -185,6 +186,12 @@ describe('OverviewTab', () => { expect(mockOnSave).toHaveBeenCalledTimes(1); }); }); + + it('renders EditorFormGroup', async () => { + const workspace = constructWorkspace(new DevWorkspaceBuilder().build()); + renderComponent(workspace); + expect(screen.getByRole('button', { name: 'Change editor' })).toBeInTheDocument(); + }); }); function getComponent(workspace: Workspace) { diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx index 7d9f432265..f5cf8bf968 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.tsx @@ -15,6 +15,7 @@ import cloneDeep from 'lodash/cloneDeep'; import React from 'react'; import AiToolFormGroup from '@/pages/WorkspaceDetails/OverviewTab/AiTool'; +import EditorFormGroup from '@/pages/WorkspaceDetails/OverviewTab/Editor'; import GitRepoFormGroup from '@/pages/WorkspaceDetails/OverviewTab/GitRepo'; import { InfrastructureNamespaceFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/InfrastructureNamespace'; import { ProjectsFormGroup } from '@/pages/WorkspaceDetails/OverviewTab/Projects'; @@ -119,6 +120,7 @@ export class OverviewTab extends React.Component { parentStorageType={parentStorageType} onSave={storageType => this.handleStorageSave(storageType)} /> + Date: Fri, 4 Sep 2026 16:34:58 +0300 Subject: [PATCH 06/16] fix(backend): register DWT DELETE route unconditionally The DELETE /namespace/:ns/devworkspacetemplates/:name route was wrapped in isLocalRun(), making it unavailable in production deployments. The changeWorkspaceEditor action creator calls deleteTemplate() after switching the editor, so the orphaned old template was never cleaned up on a real cluster. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../__tests__/devworkspaceTemplates.spec.ts | 28 ------------- .../src/routes/api/devworkspaceTemplates.ts | 42 +++++++++---------- 2 files changed, 19 insertions(+), 51 deletions(-) diff --git a/packages/dashboard-backend/src/routes/api/__tests__/devworkspaceTemplates.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/devworkspaceTemplates.spec.ts index 04953bae34..2ab8831e01 100644 --- a/packages/dashboard-backend/src/routes/api/__tests__/devworkspaceTemplates.spec.ts +++ b/packages/dashboard-backend/src/routes/api/__tests__/devworkspaceTemplates.spec.ts @@ -92,34 +92,6 @@ describe('DevWorkspaceTemplates Routes', () => { expect(res.json()).toEqual(stubDevWorkspaceTemplate); }); - test('DELETE ${baseApiPath}/namespace/:namespace/devworkspacetemplates/:templateName', async () => { - const templateName = 'tmpl'; - const res = await app - .inject() - .delete(`${baseApiPath}/namespace/${namespace}/devworkspacetemplates/${templateName}`); - - expect(res.statusCode).toEqual(404); - }); - }); - - describe('LocalRun only', () => { - beforeAll(async () => { - const env = { - OPENSHIFT_CONSOLE_URL: clusterConsoleUrl, - LOCAL_RUN: 'true', - CHE_API_PROXY_UPSTREAM: 'http://127.0.0.1:80', - }; - app = await setup({ env }); - }); - - afterAll(() => { - teardown(app); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - test('DELETE ${baseApiPath}/namespace/:namespace/devworkspacetemplates/:templateName', async () => { const templateName = 'tmpl'; const res = await app diff --git a/packages/dashboard-backend/src/routes/api/devworkspaceTemplates.ts b/packages/dashboard-backend/src/routes/api/devworkspaceTemplates.ts index 55500ec0ca..252294537a 100644 --- a/packages/dashboard-backend/src/routes/api/devworkspaceTemplates.ts +++ b/packages/dashboard-backend/src/routes/api/devworkspaceTemplates.ts @@ -19,7 +19,6 @@ import { namespacedTemplateSchema, templateStartedSchema, } from '@/constants/schemas'; -import { isLocalRun } from '@/localRun'; import { restParams } from '@/models'; import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient'; import { getToken } from '@/routes/api/helpers/getToken'; @@ -89,30 +88,27 @@ export function registerDevWorkspaceTemplates(instance: FastifyInstance) { }, ); - if (isLocalRun()) { - server.delete( - `${baseApiPath}/namespace/:namespace/devworkspacetemplates/:templateName`, - getSchema({ - tags, - params: namespacedTemplateSchema, - response: { - 204: { - description: 'The DevWorkspaceTemplate successfully deleted', - type: 'null', - }, + server.delete( + `${baseApiPath}/namespace/:namespace/devworkspacetemplates/:templateName`, + getSchema({ + tags, + params: namespacedTemplateSchema, + response: { + 204: { + description: 'The DevWorkspaceTemplate successfully deleted', + type: 'null', }, - }), - async function (request: FastifyRequest, reply: FastifyReply) { - const { namespace, templateName } = - request.params as restParams.INamespacedTemplateParams; - const token = getToken(request); - const { devWorkspaceTemplateApi: templateApi } = getDevWorkspaceClient(token); + }, + }), + async function (request: FastifyRequest, reply: FastifyReply) { + const { namespace, templateName } = request.params as restParams.INamespacedTemplateParams; + const token = getToken(request); + const { devWorkspaceTemplateApi: templateApi } = getDevWorkspaceClient(token); - await templateApi.delete(namespace, templateName); + await templateApi.delete(namespace, templateName); - reply.code(204).send(); - }, - ); - } + reply.code(204).send(); + }, + ); }); } From 98ada5e3a20d78da3712ec5dc4567dff04bc8407 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 16:35:18 +0300 Subject: [PATCH 07/16] fix(workspace-details): polish Change Editor UI - Overview tab: remove version from editor label; hide pencil button (not just disable) when workspace is not stopped/failed; add tooltip icon (Popover + FormGroupLabelHelp) matching AI Tool row alignment - Change Editor modal: fixed header (title + filter) does not scroll with the list; filter now searches display name, all versions, and all descriptions; filter placeholder shortened to "Filter by"; filter input capped at max-width 30%; version labels shown on all rows, not just the selected one; checkbox style (type=checkbox) instead of radio; versionLabel style matches AI Tool (height, margin-left, no min-width); custom editor shown as informational item when current editor ID is unknown to the registry - After confirming an editor change, show "Workspace has been updated" success alert or a danger alert with the error message, matching the AI Tool behaviour (uses AppAlerts via lazyInject) Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../Editor/SelectorModal.module.css | 30 ++++- .../OverviewTab/Editor/SelectorModal.tsx | 126 ++++++++++++------ .../Editor/__tests__/SelectorModal.spec.tsx | 69 +++++++--- .../Editor/__tests__/index.spec.tsx | 45 +++++-- .../OverviewTab/Editor/index.tsx | 69 +++++++--- .../services/helpers/__tests__/editor.spec.ts | 4 +- .../src/services/helpers/editor.ts | 2 +- 7 files changed, 259 insertions(+), 86 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css index 6bb96d0f36..0ebba50198 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css @@ -10,12 +10,36 @@ * Red Hat, Inc. - initial API and implementation */ -.radioLabel { +.editorSelectHeader { display: flex; - gap: 8px; + gap: 1rem; + align-items: center; + margin-bottom: 0.75rem; +} + +.filterInput { + max-width: 30%; +} + +.editorList { + overflow-y: auto; + max-height: 18rem; +} + +.radioLabel { + display: inline-flex; + gap: 4px; align-items: center; } .versionLabel { - cursor: default; + height: 0.8125rem; + margin-left: 0; +} + +.customEditorRow { + display: inline-flex; + gap: 4px; + align-items: center; + color: var(--pf-t--global--text--color--subtle); } diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index a5dfd555cc..794c07b099 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -12,6 +12,7 @@ import { Button, + Checkbox, Content, ContentVariants, Dropdown, @@ -25,7 +26,7 @@ import { ModalFooter, ModalHeader, ModalVariant, - Radio, + TextInput, } from '@patternfly/react-core'; import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; import React from 'react'; @@ -46,6 +47,7 @@ type State = { selectedGroupKey: string; selectedVersion: string; openDropdownId: string | null; + filterText: string; }; function resolveInitialState(currentEditorId: string | undefined, groups: EditorGroup[]): State { @@ -55,13 +57,13 @@ function resolveInitialState(currentEditorId: string | undefined, groups: Editor selectedGroupKey: first?.key ?? '', selectedVersion: first?.versions[0]?.version ?? '', openDropdownId: null, + filterText: '', }; } - // currentEditorId is "publisher/name/version" const parts = currentEditorId.split('/'); const version = parts[parts.length - 1]; const key = parts.slice(0, -1).join('/'); - return { selectedGroupKey: key, selectedVersion: version, openDropdownId: null }; + return { selectedGroupKey: key, selectedVersion: version, openDropdownId: null, filterText: '' }; } export class EditorSelectorModal extends React.PureComponent { @@ -79,6 +81,10 @@ export class EditorSelectorModal extends React.PureComponent { const groups = groupEditorsByName(this.props.editors); this.setState(resolveInitialState(this.props.currentEditorId, groups)); } + // Reset filter when modal closes + if (prevProps.isOpen && !this.props.isOpen) { + this.setState({ filterText: '' }); + } } private get selectedEditorId(): string { @@ -155,11 +161,30 @@ export class EditorSelectorModal extends React.PureComponent { } public render(): React.ReactNode { - const { isOpen, editors, onConfirm, onClose } = this.props; - const { selectedGroupKey, selectedVersion } = this.state; + const { isOpen, currentEditorId, editors, onConfirm, onClose } = this.props; + const { selectedGroupKey, selectedVersion, filterText } = this.state; const groups = groupEditorsByName(editors); + const currentGroupKey = currentEditorId + ? currentEditorId.split('/').slice(0, -1).join('/') + : undefined; + const isCustomEditor = + currentGroupKey !== undefined && !groups.some(g => g.key === currentGroupKey); + + const lowerFilter = filterText.toLowerCase(); + const filteredGroups = filterText + ? groups.filter( + g => + g.displayName.toLowerCase().includes(lowerFilter) || + g.versions.some( + v => + v.version.toLowerCase().includes(lowerFilter) || + (v.description ?? '').toLowerCase().includes(lowerFilter), + ), + ) + : groups; + return ( { > - +
{groups.length === 0 ? ( No editors are available. ) : ( <> - Select editor - {groups.map(group => { - const isGroupSelected = selectedGroupKey === group.key; - const activeVersion = isGroupSelected - ? selectedVersion - : group.versions[0].version; - const versionDropdown = this.buildVersionDropdown(group); - - const radioLabel = ( - - {group.displayName} - {isGroupSelected && ( -
- + + + + } + > + {readonly && {label}} + {!readonly && ( + + {label} + + + )} { makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), ]; - it('returns "displayName · version" when the editor is found in the list', () => { + it('returns displayName when the editor is found in the list', () => { const dw = new DevWorkspaceBuilder() .withMetadata({ annotations: { 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest' }, }) .build(); const workspace = constructWorkspace(dw); - expect(getCurrentEditorLabel(workspace, editors)).toBe('VS Code - Open Source · latest'); + expect(getCurrentEditorLabel(workspace, editors)).toBe('VS Code - Open Source'); }); it('returns the raw id when the editor is not in the list', () => { diff --git a/packages/dashboard-frontend/src/services/helpers/editor.ts b/packages/dashboard-frontend/src/services/helpers/editor.ts index 9490059fe1..af0b9a5d49 100644 --- a/packages/dashboard-frontend/src/services/helpers/editor.ts +++ b/packages/dashboard-frontend/src/services/helpers/editor.ts @@ -98,5 +98,5 @@ export function getCurrentEditorLabel(workspace: Workspace, editors: che.Plugin[ return 'Default'; } const found = editors.find(e => e.id === id); - return found ? `${found.displayName || found.name} · ${found.version}` : id; + return found ? found.displayName || found.name : id; } From 133683f42f8d97d7ce6a6686978f0b8fdc098054 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 16:43:23 +0300 Subject: [PATCH 08/16] fix(workspace-details): rename filter aria-label to "Filter editors by" Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx | 2 +- .../OverviewTab/Editor/__tests__/SelectorModal.spec.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index 794c07b099..b8fcd61981 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -208,7 +208,7 @@ export class EditorSelectorModal extends React.PureComponent { placeholder="Filter by" value={filterText} onChange={(_event, value) => this.setState({ filterText: value })} - aria-label="Filter editors by name" + aria-label="Filter editors by" className={styles.filterInput} /> diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx index a833bcc935..134ad2b354 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -156,7 +156,7 @@ describe('EditorSelectorModal', () => { it('filters editors by display name', async () => { renderComponent(true, undefined); - const filter = screen.getByRole('searchbox', { name: /Filter editors by name/i }); + const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'JetBrains'); expect(screen.queryByRole('checkbox', { name: /VS Code/i })).not.toBeInTheDocument(); expect(screen.getByRole('checkbox', { name: /JetBrains IntelliJ IDEA/i })).toBeInTheDocument(); @@ -164,7 +164,7 @@ describe('EditorSelectorModal', () => { it('filters editors by version string', async () => { renderComponent(true, undefined); - const filter = screen.getByRole('searchbox', { name: /Filter editors by name/i }); + const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'insiders'); expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); expect(screen.queryByRole('checkbox', { name: /JetBrains/i })).not.toBeInTheDocument(); @@ -172,7 +172,7 @@ describe('EditorSelectorModal', () => { it('filters editors by description text', async () => { renderComponent(true, undefined); - const filter = screen.getByRole('searchbox', { name: /Filter editors by name/i }); + const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'Open Source IDE'); expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); expect(screen.queryByRole('checkbox', { name: /JetBrains/i })).not.toBeInTheDocument(); @@ -180,7 +180,7 @@ describe('EditorSelectorModal', () => { it('shows "No editors match the filter" when filter yields no results', async () => { renderComponent(true, undefined); - const filter = screen.getByRole('searchbox', { name: /Filter editors by name/i }); + const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'NonExistentEditor'); expect(screen.getByText('No editors match the filter.')).toBeInTheDocument(); }); From b0a131fd602c8bcedf61da89bb10ccc3e66e4ba1 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 17:09:15 +0300 Subject: [PATCH 09/16] fix(workspace-details): adjust Overview tab readonly/editable alignment Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../src/pages/WorkspaceDetails/OverviewTab/index.module.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css index 01d01386f8..26ff4e5c52 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/index.module.css @@ -12,13 +12,13 @@ .editable { position: relative; - top: 2px; + bottom: 2px; color: var(--pf-t--global--text--color--link--default); } .readonly { display: inline-block; - padding-top: 8px; + padding-top: 5px; } .labelHelp { From 2b372aea732c9c826c7771730b8e4aca9ecb41e5 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Fri, 4 Sep 2026 18:08:51 +0300 Subject: [PATCH 10/16] test(workspace-details): improve coverage for Change Editor feature - devWorkspaceTemplateApi: add deleteTemplate success + error tests - changeWorkspaceEditor: add error-path and same-name-skip tests - editor helpers: add displayName/name fallback tests - SelectorModal: add filter-reset-on-close test - EditorFormGroup: add architecture-filter test Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../Editor/__tests__/SelectorModal.spec.tsx | 16 +++++++++ .../Editor/__tests__/index.spec.tsx | 28 +++++++++++++-- .../__tests__/devWorkspaceTemplateApi.spec.ts | 21 +++++++++++ .../services/helpers/__tests__/editor.spec.ts | 27 ++++++++++++++ .../__tests__/changeWorkspaceEditor.spec.ts | 35 +++++++++++++++++++ 5 files changed, 125 insertions(+), 2 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx index 134ad2b354..8b178df521 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -190,4 +190,20 @@ describe('EditorSelectorModal', () => { expect(screen.getByText('custom-publisher/my-editor/dev')).toBeInTheDocument(); expect(screen.getByText('custom')).toBeInTheDocument(); }); + + it('resets the filter when the modal is closed then reopened', async () => { + const { reRenderComponent } = renderComponent(true, undefined); + // apply a filter + await userEvent.type( + screen.getByRole('searchbox', { name: /Filter editors by/i }), + 'JetBrains', + ); + expect(screen.queryByRole('checkbox', { name: /VS Code/i })).not.toBeInTheDocument(); + + // close + reRenderComponent(false, undefined); + // reopen — filter must be cleared + reRenderComponent(true, undefined); + expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); + }); }); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx index 7de76a51ab..7bef542067 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/index.spec.tsx @@ -10,6 +10,7 @@ * Red Hat, Inc. - initial API and implementation */ +import { Architecture } from '@eclipse-che/common'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -51,6 +52,11 @@ const editors = [ makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), ]; +const amd64OnlyEditor: che.Plugin = { + ...makePlugin('che-incubator', 'amd64-only', 'latest', 'AMD64 Only Editor'), + arch: ['amd64'], +}; + const mockChangeEditor = jest.fn().mockResolvedValue(undefined); function buildWorkspace(editorId?: string): Workspace { @@ -61,13 +67,13 @@ function buildWorkspace(editorId?: string): Workspace { return constructWorkspace(builder.build()); } -function getComponent(readonly: boolean, workspace: Workspace): React.ReactElement { +function getComponent(readonly: boolean, workspace: Workspace, arch?: string): React.ReactElement { return ( ); @@ -137,6 +143,24 @@ describe('EditorFormGroup', () => { ); }); + it('filters editors by architecture when currentArchitecture is set', () => { + // amd64OnlyEditor should be excluded when architecture is arm64 + const { renderComponent: render } = getComponentRenderer( + (readonly: boolean, workspace: Workspace) => ( + + ), + ); + render(false, buildWorkspace('che-incubator/amd64-only/latest')); + // label falls back to raw id since amd64-only is filtered out + expect(screen.getByText('che-incubator/amd64-only/latest')).toBeInTheDocument(); + }); + it('closes the modal without calling changeEditor when Close Modal is clicked', async () => { renderComponent(false, buildWorkspace('che-incubator/che-code/latest')); await userEvent.click(screen.getByRole('button', { name: /Change editor/i })); diff --git a/packages/dashboard-frontend/src/services/backend-client/__tests__/devWorkspaceTemplateApi.spec.ts b/packages/dashboard-frontend/src/services/backend-client/__tests__/devWorkspaceTemplateApi.spec.ts index f832fc1405..59bc7aae61 100644 --- a/packages/dashboard-frontend/src/services/backend-client/__tests__/devWorkspaceTemplateApi.spec.ts +++ b/packages/dashboard-frontend/src/services/backend-client/__tests__/devWorkspaceTemplateApi.spec.ts @@ -15,6 +15,7 @@ import mockAxios from 'axios'; import { createTemplate, + deleteTemplate, getTemplateByName, getTemplates, patchTemplate, @@ -25,6 +26,7 @@ describe('DevWorkspaceTemplate API', () => { const mockGet = mockAxios.get as jest.Mock; const mockPost = mockAxios.post as jest.Mock; const mockPatch = mockAxios.patch as jest.Mock; + const mockDelete = mockAxios.delete as jest.Mock; const namespace = 'test-name'; const devWorkspaceTemplateName = 'che-code'; @@ -143,4 +145,23 @@ describe('DevWorkspaceTemplate API', () => { expect(res).toEqual(devWorkspaceTemplate); }); }); + + describe('delete a DevWorkspaceTemplate', () => { + it('should call the correct URL on success', async () => { + mockDelete.mockResolvedValueOnce(new Promise(resolve => resolve({ data: undefined }))); + await deleteTemplate(namespace, devWorkspaceTemplateName); + + expect(mockDelete).toHaveBeenCalledWith( + '/dashboard/api/namespace/test-name/devworkspacetemplates/che-code', + undefined, + ); + }); + + it('should throw a wrapped error when the request fails', async () => { + mockDelete.mockRejectedValueOnce(new Error('network failure')); + await expect(deleteTemplate(namespace, devWorkspaceTemplateName)).rejects.toThrow( + "Failed to delete devWorkspaceTemplate 'che-code'", + ); + }); + }); }); diff --git a/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts index ed48dd9b98..369509b346 100644 --- a/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts @@ -68,6 +68,19 @@ describe('groupEditorsByName', () => { expect(groups[0].key).toBe('che-incubator/che-idea-server'); expect(groups[1].key).toBe('che-incubator/che-code'); }); + + it('falls back to plugin name when displayName is empty', () => { + const plugin: che.Plugin = { + ...makePlugin('che-incubator', 'che-code', 'latest', ''), + displayName: '', + icon: '', + iconMediatype: '', + }; + const groups = groupEditorsByName([plugin]); + expect(groups[0].displayName).toBe('che-code'); + expect(groups[0].icon).toBe(''); + expect(groups[0].iconMediatype).toBe(''); + }); }); describe('getCurrentEditorId', () => { @@ -117,4 +130,18 @@ describe('getCurrentEditorLabel', () => { const workspace = constructWorkspace(dw); expect(getCurrentEditorLabel(workspace, editors)).toBe('Default'); }); + + it('falls back to plugin name when found editor has no displayName', () => { + const editorWithoutDisplayName: che.Plugin = { + ...makePlugin('che-incubator', 'che-code', 'latest', ''), + displayName: '', + }; + const dw = new DevWorkspaceBuilder() + .withMetadata({ + annotations: { 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest' }, + }) + .build(); + const workspace = constructWorkspace(dw); + expect(getCurrentEditorLabel(workspace, [editorWithoutDisplayName])).toBe('che-code'); + }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts index 32ec78bc93..bbfddf23ce 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts @@ -150,4 +150,39 @@ describe('changeWorkspaceEditor', () => { store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/unknown-editor/latest')), ).rejects.toThrow('not found'); }); + + it('rethrows when patchWorkspace fails', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + mockPatchWorkspace.mockRejectedValueOnce(new Error('patch failed')); + + await expect( + store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')), + ).rejects.toThrow('patch failed'); + }); + + it('skips delete when old and new template names are the same', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + // workspace already points to the target editor + const dw = new DevWorkspaceBuilder() + .withMetadata({ + name: 'empty-ido0', + namespace: 'test-ns', + uid: 'test-uid', + annotations: { + ...wsAnnotations, + 'che.eclipse.org/che-editor': 'che-incubator/che-idea-server/latest', + }, + }) + .withContributions([{ name: 'editor', kubernetes: { name: 'che-idea-server-empty-ido0' } }]) + .build(); + const workspace = constructWorkspace(dw); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockDeleteTemplate).not.toHaveBeenCalled(); + }); }); From 31adaaf0a1b299557d8524e90e91e28c548ca3e2 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Mon, 7 Sep 2026 15:54:25 +0300 Subject: [PATCH 11/16] fix(workspace-details): harden Change Editor action and modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use findIndex to locate the editor contribution by name instead of hard-coding index 0; fixes incorrect patch when other contributions (e.g. AI tool) precede the editor entry - Wrap old-template deletion in its own try/catch so a stale-resource error does not surface as a failed editor change after the workspace is already patched - Guard devfile-source regex replacement — only run it when the annotation actually contains che-editor=, leaving unrelated sources unchanged - Replace Checkbox with Radio in EditorSelectorModal so screen readers correctly announce single-selection semantics Assisted-by: Claude Opus 4.6 Signed-off-by: Oleksii Orel --- .../OverviewTab/Editor/SelectorModal.tsx | 6 +- .../Editor/__tests__/SelectorModal.spec.tsx | 32 +++---- .../__tests__/changeWorkspaceEditor.spec.ts | 89 +++++++++++++++++++ .../actionCreators/changeWorkspaceEditor.ts | 27 ++++-- 4 files changed, 130 insertions(+), 24 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index b8fcd61981..7faf045965 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -12,7 +12,6 @@ import { Button, - Checkbox, Content, ContentVariants, Dropdown, @@ -26,6 +25,7 @@ import { ModalFooter, ModalHeader, ModalVariant, + Radio, TextInput, } from '@patternfly/react-core'; import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; @@ -245,12 +245,14 @@ export class EditorSelectorModal extends React.PureComponent { return ( - this.handleSelectGroup(group)} + value={group.key} /> ); diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx index 8b178df521..765b793709 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -78,17 +78,17 @@ describe('EditorSelectorModal', () => { expect(screen.getByText('Change Editor')).toBeInTheDocument(); }); - it('renders one checkbox per editor group', () => { + it('renders one radio per editor group', () => { renderComponent(true, undefined); - const checkboxes = screen.getAllByRole('checkbox'); + const radios = screen.getAllByRole('radio'); // 2 groups: che-code, che-idea-server - expect(checkboxes).toHaveLength(2); + expect(radios).toHaveLength(2); }); - it('pre-selects the checkbox that matches currentEditorId', () => { + it('pre-selects the radio that matches currentEditorId', () => { renderComponent(true, 'che-incubator/che-idea-server/latest'); - const checkbox = screen.getByRole('checkbox', { name: /JetBrains IntelliJ IDEA/i }); - expect(checkbox).toBeChecked(); + const radio = screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i }); + expect(radio).toBeChecked(); }); it('Save button is disabled when selection has not changed', () => { @@ -98,13 +98,13 @@ describe('EditorSelectorModal', () => { it('Save button becomes enabled after selecting a different editor', async () => { renderComponent(true, 'che-incubator/che-code/latest'); - await userEvent.click(screen.getByRole('checkbox', { name: /JetBrains IntelliJ IDEA/i })); + await userEvent.click(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })); expect(screen.getByRole('button', { name: /Save/i })).not.toBeDisabled(); }); it('calls onConfirm with the selected editor id on Save', async () => { renderComponent(true, 'che-incubator/che-code/latest'); - await userEvent.click(screen.getByRole('checkbox', { name: /JetBrains IntelliJ IDEA/i })); + await userEvent.click(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })); await userEvent.click(screen.getByRole('button', { name: /Save/i })); expect(mockOnConfirm).toHaveBeenCalledWith('che-incubator/che-idea-server/latest'); }); @@ -158,24 +158,24 @@ describe('EditorSelectorModal', () => { renderComponent(true, undefined); const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'JetBrains'); - expect(screen.queryByRole('checkbox', { name: /VS Code/i })).not.toBeInTheDocument(); - expect(screen.getByRole('checkbox', { name: /JetBrains IntelliJ IDEA/i })).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /VS Code/i })).not.toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })).toBeInTheDocument(); }); it('filters editors by version string', async () => { renderComponent(true, undefined); const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'insiders'); - expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); - expect(screen.queryByRole('checkbox', { name: /JetBrains/i })).not.toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /VS Code/i })).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /JetBrains/i })).not.toBeInTheDocument(); }); it('filters editors by description text', async () => { renderComponent(true, undefined); const filter = screen.getByRole('searchbox', { name: /Filter editors by/i }); await userEvent.type(filter, 'Open Source IDE'); - expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); - expect(screen.queryByRole('checkbox', { name: /JetBrains/i })).not.toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /VS Code/i })).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /JetBrains/i })).not.toBeInTheDocument(); }); it('shows "No editors match the filter" when filter yields no results', async () => { @@ -198,12 +198,12 @@ describe('EditorSelectorModal', () => { screen.getByRole('searchbox', { name: /Filter editors by/i }), 'JetBrains', ); - expect(screen.queryByRole('checkbox', { name: /VS Code/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: /VS Code/i })).not.toBeInTheDocument(); // close reRenderComponent(false, undefined); // reopen — filter must be cleared reRenderComponent(true, undefined); - expect(screen.getByRole('checkbox', { name: /VS Code/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /VS Code/i })).toBeInTheDocument(); }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts index bbfddf23ce..b3d166ab56 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts @@ -133,6 +133,95 @@ describe('changeWorkspaceEditor', () => { ); }); + it('patches the correct contributions index when editor is not at index 0', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const dw = new DevWorkspaceBuilder() + .withMetadata({ + name: 'empty-ido0', + namespace: 'test-ns', + uid: 'test-uid', + annotations: wsAnnotations, + }) + .withContributions([ + { name: 'ai-tool', kubernetes: { name: 'ai-tool-empty-ido0' } }, + { name: 'editor', kubernetes: { name: 'che-code-empty-ido0' } }, + ]) + .build(); + const workspace = constructWorkspace(dw); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(mockPatchWorkspace).toHaveBeenCalledWith( + 'test-ns', + 'empty-ido0', + expect.arrayContaining([ + expect.objectContaining({ + path: '/spec/contributions/1/kubernetes/name', + value: 'che-idea-server-empty-ido0', + }), + ]), + ); + }); + + it('throws when editor contribution is absent from the workspace', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const dw = new DevWorkspaceBuilder() + .withMetadata({ name: 'empty-ido0', namespace: 'test-ns', uid: 'test-uid' }) + .withContributions([{ name: 'ai-tool', kubernetes: { name: 'ai-tool-empty-ido0' } }]) + .build(); + const workspace = constructWorkspace(dw); + + await expect( + store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')), + ).rejects.toThrow('Editor contribution not found'); + }); + + it('succeeds even when deleteTemplate throws', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + mockDeleteTemplate.mockRejectedValueOnce(new Error('already gone')); + + await expect( + store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')), + ).resolves.not.toThrow(); + expect(mockPatchWorkspace).toHaveBeenCalledTimes(1); + }); + + it('leaves devfile-source unchanged when che-editor= is absent', async () => { + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const devfileSourceWithoutEditor = 'url:\n location: https://example.com\n'; + const dw = new DevWorkspaceBuilder() + .withMetadata({ + name: 'empty-ido0', + namespace: 'test-ns', + uid: 'test-uid', + annotations: { + 'che.eclipse.org/che-editor': 'che-incubator/che-code/latest', + 'che.eclipse.org/devfile-source': devfileSourceWithoutEditor, + }, + }) + .withContributions([{ name: 'editor', kubernetes: { name: 'che-code-empty-ido0' } }]) + .build(); + const workspace = constructWorkspace(dw); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + const patches = mockPatchWorkspace.mock.calls[0][2] as Array<{ + path: string; + value: Record; + }>; + const annotationsPatch = patches.find(p => p.path === '/metadata/annotations'); + expect(annotationsPatch?.value['che.eclipse.org/devfile-source']).toBe( + devfileSourceWithoutEditor, + ); + }); + it('deletes the old template', async () => { const store = new MockStoreBuilder() .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts index 5db43b4ad0..e3bb67c3ce 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts @@ -114,24 +114,39 @@ export const changeWorkspaceEditor = ); // Step 2: patch workspace — annotations + spec.contributions + const editorIndex = (dw.spec?.contributions ?? []).findIndex(c => c.name === 'editor'); + if (editorIndex === -1) { + throw new Error(`Editor contribution not found in workspace ${workspaceName}`); + } + const annotations = { ...(dw.metadata.annotations ?? {}) }; annotations[DEVWORKSPACE_CHE_EDITOR] = newEditorId; const devfileSource = annotations[DEVWORKSPACE_DEVFILE_SOURCE] ?? ''; - annotations[DEVWORKSPACE_DEVFILE_SOURCE] = devfileSource.replace( - /che-editor=[^&\n]+/, - `che-editor=${newEditorId}`, - ); + if (devfileSource.includes('che-editor=')) { + annotations[DEVWORKSPACE_DEVFILE_SOURCE] = devfileSource.replace( + /che-editor=[^&\n]+/, + `che-editor=${newEditorId}`, + ); + } const { devWorkspace: updatedDw } = await DwApi.patchWorkspace(namespace, workspaceName, [ { op: 'replace', path: '/metadata/annotations', value: annotations }, - { op: 'replace', path: '/spec/contributions/0/kubernetes/name', value: newTemplateName }, + { + op: 'replace', + path: `/spec/contributions/${editorIndex}/kubernetes/name`, + value: newTemplateName, + }, ]); dispatch(devWorkspacesUpdateAction(updatedDw)); // Step 3: delete old template (ownerRef also GC-s it on workspace delete — explicit for cleanliness) const oldTemplateName = getEditorName(dw); if (oldTemplateName && oldTemplateName !== newTemplateName) { - await DwtApi.deleteTemplate(namespace, oldTemplateName); + try { + await DwtApi.deleteTemplate(namespace, oldTemplateName); + } catch { + // Old template may already be gone — ownerRef GC handles it + } } } catch (e) { const errorMessage = From efa10707f637420bf6530212058ff0f01249ffda Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Tue, 8 Sep 2026 01:07:26 +0300 Subject: [PATCH 12/16] fix(workspace-details): preserve version in Change Editor modal When switching editor groups the modal always reset the version to group.versions[0].version. If the first version in the list did not match the current editor's version (e.g. insiders before latest), going back to the original editor showed the wrong version. Replace selectedVersion: string with versionsByGroup: Record so each group independently tracks its selected version for the duration of the modal session. State is reset on modal open so a previous session's choices never persist across Cancel/reopen. Adds three regression tests covering the reported scenario. Assisted-by: Claude Sonnet 4.6 Signed-off-by: Oleksii Orel --- .../OverviewTab/Editor/SelectorModal.tsx | 43 +++++++----- .../Editor/__tests__/SelectorModal.spec.tsx | 68 +++++++++++++++++++ 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index 7faf045965..86ccda7bbb 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -45,17 +45,21 @@ export type Props = { type State = { selectedGroupKey: string; - selectedVersion: string; + versionsByGroup: Record; openDropdownId: string | null; filterText: string; }; function resolveInitialState(currentEditorId: string | undefined, groups: EditorGroup[]): State { + const versionsByGroup: Record = {}; if (!currentEditorId) { const first = groups[0]; + if (first) { + versionsByGroup[first.key] = first.versions[0]?.version ?? ''; + } return { selectedGroupKey: first?.key ?? '', - selectedVersion: first?.versions[0]?.version ?? '', + versionsByGroup, openDropdownId: null, filterText: '', }; @@ -63,7 +67,8 @@ function resolveInitialState(currentEditorId: string | undefined, groups: Editor const parts = currentEditorId.split('/'); const version = parts[parts.length - 1]; const key = parts.slice(0, -1).join('/'); - return { selectedGroupKey: key, selectedVersion: version, openDropdownId: null, filterText: '' }; + versionsByGroup[key] = version; + return { selectedGroupKey: key, versionsByGroup, openDropdownId: null, filterText: '' }; } export class EditorSelectorModal extends React.PureComponent { @@ -76,7 +81,8 @@ export class EditorSelectorModal extends React.PureComponent { public componentDidUpdate(prevProps: Props): void { if ( prevProps.currentEditorId !== this.props.currentEditorId || - prevProps.editors !== this.props.editors + prevProps.editors !== this.props.editors || + (!prevProps.isOpen && this.props.isOpen) ) { const groups = groupEditorsByName(this.props.editors); this.setState(resolveInitialState(this.props.currentEditorId, groups)); @@ -88,7 +94,8 @@ export class EditorSelectorModal extends React.PureComponent { } private get selectedEditorId(): string { - return `${this.state.selectedGroupKey}/${this.state.selectedVersion}`; + const version = this.state.versionsByGroup[this.state.selectedGroupKey] ?? ''; + return `${this.state.selectedGroupKey}/${version}`; } private get hasChanged(): boolean { @@ -96,11 +103,14 @@ export class EditorSelectorModal extends React.PureComponent { } private handleSelectGroup(group: EditorGroup): void { - this.setState({ + this.setState(prevState => ({ selectedGroupKey: group.key, - selectedVersion: group.versions[0].version, openDropdownId: null, - }); + versionsByGroup: + prevState.versionsByGroup[group.key] !== undefined + ? prevState.versionsByGroup + : { ...prevState.versionsByGroup, [group.key]: group.versions[0].version }, + })); } private handleVersionSelect( @@ -110,17 +120,20 @@ export class EditorSelectorModal extends React.PureComponent { ): void { event.stopPropagation(); event.preventDefault(); - this.setState({ selectedVersion: version, openDropdownId: null, selectedGroupKey: groupKey }); + this.setState(prevState => ({ + versionsByGroup: { ...prevState.versionsByGroup, [groupKey]: version }, + openDropdownId: null, + selectedGroupKey: groupKey, + })); } private buildVersionDropdown(group: EditorGroup): React.ReactElement | null { if (group.versions.length <= 1) { return null; } - const { openDropdownId, selectedVersion, selectedGroupKey } = this.state; + const { openDropdownId, versionsByGroup } = this.state; const isOpen = openDropdownId === group.key; - const activeVersion = - selectedGroupKey === group.key ? selectedVersion : group.versions[0].version; + const activeVersion = versionsByGroup[group.key] ?? group.versions[0].version; return ( { public render(): React.ReactNode { const { isOpen, currentEditorId, editors, onConfirm, onClose } = this.props; - const { selectedGroupKey, selectedVersion, filterText } = this.state; + const { selectedGroupKey, versionsByGroup, filterText } = this.state; const groups = groupEditorsByName(editors); @@ -228,9 +241,7 @@ export class EditorSelectorModal extends React.PureComponent { ) : ( filteredGroups.map(group => { const isGroupSelected = selectedGroupKey === group.key; - const activeVersion = isGroupSelected - ? selectedVersion - : group.versions[0].version; + const activeVersion = versionsByGroup[group.key] ?? group.versions[0].version; const versionDropdown = this.buildVersionDropdown(group); const radioLabel = ( diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx index 765b793709..a31e96bb78 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -191,6 +191,74 @@ describe('EditorSelectorModal', () => { expect(screen.getByText('custom')).toBeInTheDocument(); }); + it('preserves preselected version when switching editor group and back', async () => { + // When insiders is listed first but current editor is latest, switching away and back + // must NOT reset the version to insiders (the first/default in the list). + const editorsInsidersFirst: che.Plugin[] = [ + makePlugin('che-incubator', 'che-code', 'insiders', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code - Open Source'), + makePlugin('che-incubator', 'che-idea-server', 'latest', 'JetBrains IntelliJ IDEA'), + ]; + function localGetComponent(isOpen: boolean, currentEditorId: string | undefined) { + return ( + + ); + } + const { renderComponent: renderLocal } = getComponentRenderer(localGetComponent); + renderLocal(true, 'che-incubator/che-code/latest'); + + // Switch to IntelliJ + await userEvent.click(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })); + // Switch back to VS Code + await userEvent.click(screen.getByRole('radio', { name: /VS Code - Open Source/i })); + + // Save is disabled because we're back to the original selection (latest, not insiders) + expect(screen.getByRole('button', { name: /Save/i })).toBeDisabled(); + }); + + it('preserves explicitly chosen version when switching away and back', async () => { + // Start with latest, change to insiders, switch to IntelliJ, switch back → still insiders + renderComponent(true, 'che-incubator/che-code/latest'); + + // Change version to insiders via dropdown + await userEvent.click( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ); + await userEvent.click(screen.getByRole('menuitem', { name: 'insiders' })); + + // Switch to IntelliJ + await userEvent.click(screen.getByRole('radio', { name: /JetBrains IntelliJ IDEA/i })); + + // Switch back to VS Code + await userEvent.click(screen.getByRole('radio', { name: /VS Code - Open Source/i })); + + // Should confirm with insiders, not latest + await userEvent.click(screen.getByRole('button', { name: /Save/i })); + expect(mockOnConfirm).toHaveBeenCalledWith('che-incubator/che-code/insiders'); + }); + + it('resets version selection when modal is reopened', async () => { + const { reRenderComponent } = renderComponent(true, 'che-incubator/che-code/latest'); + + // Change version to insiders — Save becomes enabled + await userEvent.click( + screen.getByRole('button', { name: /VS Code - Open Source version options/i }), + ); + await userEvent.click(screen.getByRole('menuitem', { name: 'insiders' })); + expect(screen.getByRole('button', { name: /Save/i })).not.toBeDisabled(); + + // Close and reopen modal — selection must reset to current editor (latest), Save disabled + reRenderComponent(false, 'che-incubator/che-code/latest'); + reRenderComponent(true, 'che-incubator/che-code/latest'); + expect(screen.getByRole('button', { name: /Save/i })).toBeDisabled(); + }); + it('resets the filter when the modal is closed then reopened', async () => { const { reRenderComponent } = renderComponent(true, undefined); // apply a filter From 5f8ef0ad4a1ca0dafbd7b8e7b22ab5a4525f5a4b Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Tue, 15 Sep 2026 16:57:00 +0300 Subject: [PATCH 13/16] fix(workspace-details): polish Change Editor modal per review Move editor description from inline text to hover tooltip, sort editors the same way as the Create Workspace editor gallery (che-code first, versions by priority, deprecated last), and adopt the shared TagLabel component for version/tag badges. Assisted-by: Claude Opus 4.6 Signed-off-by: Oleksii Orel --- .../Editor/SelectorModal.module.css | 8 ++-- .../OverviewTab/Editor/SelectorModal.tsx | 28 +++++++++---- .../Editor/__tests__/SelectorModal.spec.tsx | 5 ++- .../services/helpers/__tests__/editor.spec.ts | 29 +++++++++++-- .../src/services/helpers/editor.ts | 42 ++++++++++++++++++- 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css index 0ebba50198..a83eb71516 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css @@ -32,9 +32,11 @@ align-items: center; } -.versionLabel { - height: 0.8125rem; - margin-left: 0; +.editorName { + cursor: default; + text-decoration-line: underline; + text-decoration-style: dotted; + text-underline-offset: 2px; } .customEditorRow { diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index 86ccda7bbb..8461999009 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -17,7 +17,6 @@ import { Dropdown, DropdownItem, DropdownList, - Label, MenuToggle, MenuToggleElement, Modal, @@ -27,10 +26,12 @@ import { ModalVariant, Radio, TextInput, + Tooltip, } from '@patternfly/react-core'; import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; import React from 'react'; +import { TagLabel } from '@/components/TagLabel'; import styles from '@/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css'; import { EditorGroup, groupEditorsByName } from '@/services/helpers/editor'; import { che } from '@/services/models'; @@ -230,9 +231,7 @@ export class EditorSelectorModal extends React.PureComponent {
{currentEditorId} - +
)} @@ -243,13 +242,25 @@ export class EditorSelectorModal extends React.PureComponent { const isGroupSelected = selectedGroupKey === group.key; const activeVersion = versionsByGroup[group.key] ?? group.versions[0].version; const versionDropdown = this.buildVersionDropdown(group); + const activeVersionEntry = group.versions.find( + v => v.version === activeVersion, + ); + const description = + activeVersionEntry?.description ?? + group.versions.find(v => v.description)?.description; + + const editorName = description ? ( + + {group.displayName} + + ) : ( + {group.displayName} + ); const radioLabel = ( - {group.displayName} - + {editorName} + {versionDropdown} ); @@ -260,7 +271,6 @@ export class EditorSelectorModal extends React.PureComponent { label={radioLabel} id={`editor-${group.key.replace(/\//g, '-')}`} name="editor-selector" - description={group.versions[0].description} isChecked={isGroupSelected} onChange={() => this.handleSelectGroup(group)} value={group.key} diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx index a31e96bb78..5674dc24da 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/__tests__/SelectorModal.spec.tsx @@ -117,8 +117,9 @@ describe('EditorSelectorModal', () => { it('shows a version label for every editor group', () => { renderComponent(true, undefined); - // VS Code (2 versions) shows 'latest' (active), IntelliJ (1 version) shows 'latest' - expect(screen.getAllByText('latest')).toHaveLength(2); + // VS Code (2 versions) defaults to 'insiders' (highest priority), IntelliJ shows 'latest' + expect(screen.getByText('insiders')).toBeInTheDocument(); + expect(screen.getByText('latest')).toBeInTheDocument(); }); it('shows version dropdown trigger for editors with multiple versions', () => { diff --git a/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts index 369509b346..d6a577bffb 100644 --- a/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts +++ b/packages/dashboard-frontend/src/services/helpers/__tests__/editor.spec.ts @@ -59,14 +59,37 @@ describe('groupEditorsByName', () => { expect(groupEditorsByName([])).toEqual([]); }); - it('preserves insertion order of groups', () => { + it('sorts che-code groups first, then alphabetical', () => { const plugins = [ makePlugin('che-incubator', 'che-idea-server', 'latest', 'IntelliJ IDEA'), makePlugin('che-incubator', 'che-code', 'latest', 'VS Code'), ]; const groups = groupEditorsByName(plugins); - expect(groups[0].key).toBe('che-incubator/che-idea-server'); - expect(groups[1].key).toBe('che-incubator/che-code'); + expect(groups[0].key).toBe('che-incubator/che-code'); + expect(groups[1].key).toBe('che-incubator/che-idea-server'); + }); + + it('sorts versions within a group by priority: insiders, next, latest', () => { + const plugins = [ + makePlugin('che-incubator', 'che-code', 'latest', 'VS Code'), + makePlugin('che-incubator', 'che-code', 'next', 'VS Code'), + makePlugin('che-incubator', 'che-code', 'insiders', 'VS Code'), + ]; + const groups = groupEditorsByName(plugins); + expect(groups[0].versions.map(v => v.version)).toEqual(['insiders', 'next', 'latest']); + }); + + it('sorts deprecated versions to the end within a group', () => { + const plugins = [ + { + ...makePlugin('che-incubator', 'che-code', 'latest', 'VS Code'), + tags: ['Deprecated'], + }, + makePlugin('che-incubator', 'che-code', 'next', 'VS Code'), + ]; + const groups = groupEditorsByName(plugins); + expect(groups[0].versions[0].version).toBe('next'); + expect(groups[0].versions[1].version).toBe('latest'); }); it('falls back to plugin name when displayName is empty', () => { diff --git a/packages/dashboard-frontend/src/services/helpers/editor.ts b/packages/dashboard-frontend/src/services/helpers/editor.ts index af0b9a5d49..6a749a0c87 100644 --- a/packages/dashboard-frontend/src/services/helpers/editor.ts +++ b/packages/dashboard-frontend/src/services/helpers/editor.ts @@ -70,9 +70,32 @@ export type EditorGroup = { versions: che.Plugin[]; }; +const VERSION_PRIORITY: ReadonlyArray = ['insiders', 'next', 'latest']; +const DEPRECATED_TAG = 'Deprecated'; +const GROUP_PRIORITY = 'che-code'; + export function groupEditorsByName(editors: che.Plugin[]): EditorGroup[] { + const sorted = [...editors].sort((a, b) => { + if (a.name === b.name) { + const aDeprecated = a.tags?.includes(DEPRECATED_TAG) ? 1 : 0; + const bDeprecated = b.tags?.includes(DEPRECATED_TAG) ? 1 : 0; + if (aDeprecated !== bDeprecated) { + return aDeprecated - bDeprecated; + } + + const aPriority = VERSION_PRIORITY.indexOf(a.version); + const bPriority = VERSION_PRIORITY.indexOf(b.version); + if (aPriority !== -1 && bPriority !== -1) { + return aPriority - bPriority; + } + if (aPriority !== -1) return -1; + if (bPriority !== -1) return 1; + } + return a.id.localeCompare(b.id); + }); + const map = new Map(); - for (const editor of editors) { + for (const editor of sorted) { const key = `${editor.publisher}/${editor.name}`; if (!map.has(key)) { map.set(key, { @@ -85,7 +108,22 @@ export function groupEditorsByName(editors: che.Plugin[]): EditorGroup[] { } map.get(key)!.versions.push(editor); } - return Array.from(map.values()); + + return Array.from(map.values()).sort((a, b) => { + const aName = a.key.split('/').pop() ?? ''; + const bName = b.key.split('/').pop() ?? ''; + if (aName.startsWith(GROUP_PRIORITY) && !bName.startsWith(GROUP_PRIORITY)) { + return -1; + } + if (bName.startsWith(GROUP_PRIORITY) && !aName.startsWith(GROUP_PRIORITY)) { + return 1; + } + if (aName.startsWith(GROUP_PRIORITY) && bName.startsWith(GROUP_PRIORITY)) { + if (aName === GROUP_PRIORITY) return -1; + if (bName === GROUP_PRIORITY) return 1; + } + return a.displayName.localeCompare(b.displayName); + }); } export function getCurrentEditorId(workspace: Workspace): string | undefined { From 08d5b902404163ec94ddf9c4f7c82eae538aa314 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Tue, 15 Sep 2026 17:44:21 +0300 Subject: [PATCH 14/16] fix(workspace-details): reduce editor list spacing and drop underline Remove dotted underline from editor names, collapse margins between editor list items, and increase list max-height from 18rem to 20rem. Assisted-by: Claude Opus 4.6 Signed-off-by: Oleksii Orel --- .../OverviewTab/Editor/SelectorModal.module.css | 13 +++++-------- .../OverviewTab/Editor/SelectorModal.tsx | 2 +- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css index a83eb71516..b4ac8fe134 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css @@ -23,7 +23,11 @@ .editorList { overflow-y: auto; - max-height: 18rem; + max-height: 20rem; +} + +.editorList > * { + margin: 0; } .radioLabel { @@ -32,13 +36,6 @@ align-items: center; } -.editorName { - cursor: default; - text-decoration-line: underline; - text-decoration-style: dotted; - text-underline-offset: 2px; -} - .customEditorRow { display: inline-flex; gap: 4px; diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index 8461999009..d9baf23f5a 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -251,7 +251,7 @@ export class EditorSelectorModal extends React.PureComponent { const editorName = description ? ( - {group.displayName} + {group.displayName} ) : ( {group.displayName} From c96a05d779ebe5c1aa5ed050debe90019311fc2d Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Tue, 15 Sep 2026 17:52:53 +0300 Subject: [PATCH 15/16] fix(workspace-details): remove Content h6 wrapper from editor list The PatternFly Content h6 elements added large block margins that could not be overridden by a CSS module selector. Removing the wrapper eliminates the spacing issue. Assisted-by: Claude Opus 4.6 Signed-off-by: Oleksii Orel --- .../Editor/SelectorModal.module.css | 4 --- .../OverviewTab/Editor/SelectorModal.tsx | 29 +++++++++---------- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css index b4ac8fe134..d0f32c49ab 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.module.css @@ -26,10 +26,6 @@ max-height: 20rem; } -.editorList > * { - margin: 0; -} - .radioLabel { display: inline-flex; gap: 4px; diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index d9baf23f5a..66ac9ee440 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -228,12 +228,10 @@ export class EditorSelectorModal extends React.PureComponent {
{isCustomEditor && ( - -
- {currentEditorId} - -
-
+
+ {currentEditorId} + +
)} {filteredGroups.length === 0 ? ( No editors match the filter. @@ -266,16 +264,15 @@ export class EditorSelectorModal extends React.PureComponent { ); return ( - - this.handleSelectGroup(group)} - value={group.key} - /> - + this.handleSelectGroup(group)} + value={group.key} + /> ); }) )} From b08dc54afe894573e7dd687b947bea4c77b22e46 Mon Sep 17 00:00:00 2001 From: Oleksii Orel Date: Mon, 21 Sep 2026 16:44:02 +0300 Subject: [PATCH 16/16] fix(workspace-details): clear stale IDE redirect on editor change Assisted-by: Claude Opus 4.6 Signed-off-by: Oleksii Orel --- .../OverviewTab/Editor/SelectorModal.tsx | 17 +---------------- .../__tests__/changeWorkspaceEditor.spec.ts | 13 +++++++++++++ .../actionCreators/changeWorkspaceEditor.ts | 5 +++++ 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx index 66ac9ee440..ecd0e575cc 100644 --- a/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx +++ b/packages/dashboard-frontend/src/pages/WorkspaceDetails/OverviewTab/Editor/SelectorModal.tsx @@ -26,7 +26,6 @@ import { ModalVariant, Radio, TextInput, - Tooltip, } from '@patternfly/react-core'; import { CheckIcon, EllipsisVIcon } from '@patternfly/react-icons'; import React from 'react'; @@ -240,24 +239,10 @@ export class EditorSelectorModal extends React.PureComponent { const isGroupSelected = selectedGroupKey === group.key; const activeVersion = versionsByGroup[group.key] ?? group.versions[0].version; const versionDropdown = this.buildVersionDropdown(group); - const activeVersionEntry = group.versions.find( - v => v.version === activeVersion, - ); - const description = - activeVersionEntry?.description ?? - group.versions.find(v => v.description)?.description; - - const editorName = description ? ( - - {group.displayName} - - ) : ( - {group.displayName} - ); const radioLabel = ( - {editorName} + {group.displayName} {versionDropdown} diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts index b3d166ab56..9e6b362435 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/__tests__/changeWorkspaceEditor.spec.ts @@ -11,6 +11,7 @@ */ import devfileApi from '@/services/devfileApi'; +import SessionStorageService, { SessionStorageKey } from '@/services/session-storage'; import { constructWorkspace } from '@/services/workspace-adapter'; import { DevWorkspaceBuilder } from '@/store/__mocks__/devWorkspaceBuilder'; import { MockStoreBuilder } from '@/store/__mocks__/mockStore'; @@ -274,4 +275,16 @@ describe('changeWorkspaceEditor', () => { expect(mockDeleteTemplate).not.toHaveBeenCalled(); }); + + it('clears ORIGINAL_LOCATION_PATH from session storage after changing editor', async () => { + const removeSpy = jest.spyOn(SessionStorageService, 'remove'); + const store = new MockStoreBuilder() + .withDwPlugins({}, {}, false, [intellijDevfile as devfileApi.Devfile]) + .build(); + const workspace = buildWorkspace(); + await store.dispatch(changeWorkspaceEditor(workspace, 'che-incubator/che-idea-server/latest')); + + expect(removeSpy).toHaveBeenCalledWith(SessionStorageKey.ORIGINAL_LOCATION_PATH); + removeSpy.mockRestore(); + }); }); diff --git a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts index e3bb67c3ce..598c24d371 100644 --- a/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts +++ b/packages/dashboard-frontend/src/store/Workspaces/devWorkspaces/actions/actionCreators/changeWorkspaceEditor.ts @@ -18,6 +18,7 @@ import * as DwApi from '@/services/backend-client/devWorkspaceApi'; import * as DwtApi from '@/services/backend-client/devWorkspaceTemplateApi'; import devfileApi from '@/services/devfileApi'; import { DEVWORKSPACE_CHE_EDITOR } from '@/services/devfileApi/devWorkspace/metadata'; +import SessionStorageService, { SessionStorageKey } from '@/services/session-storage'; import { Workspace } from '@/services/workspace-adapter'; import { COMPONENT_UPDATE_POLICY, @@ -148,6 +149,10 @@ export const changeWorkspaceEditor = // Old template may already be gone — ownerRef GC handles it } } + + // Step 4: clear the stored IDE page path so the dashboard does not + // redirect to the previous editor's landing page on next workspace start + SessionStorageService.remove(SessionStorageKey.ORIGINAL_LOCATION_PATH); } catch (e) { const errorMessage = `Failed to change editor for workspace ${workspaceName}, reason: ` +