diff --git a/src/style.d.ts b/src/style.d.ts new file mode 100644 index 000000000..6efdb8f40 --- /dev/null +++ b/src/style.d.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2025-2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +declare module '*.css'; diff --git a/src/views/manage-solution/manage-solution-webview-main.test.ts b/src/views/manage-solution/manage-solution-webview-main.test.ts index 4a1b0ec20..9be33b051 100644 --- a/src/views/manage-solution/manage-solution-webview-main.test.ts +++ b/src/views/manage-solution/manage-solution-webview-main.test.ts @@ -63,8 +63,10 @@ describe('ContextSelectionWebviewMain', () => { }); it('sends selected context data on GET_CONTEXT_SELECTION_DATA', async () => { + const configurationProvider = configurationProviderFactory(); const main = manageSolutionWebviewMainFactory({ - webviewManager + webviewManager, + configurationProvider }); await main.activate(context as unknown as vscode.ExtensionContext); @@ -549,7 +551,7 @@ describe('ContextSelectionWebviewMain', () => { ]); await fireAndWait('SELECT_FILE', { - targetElementId: 'image-path', + requestId: 'image-path', options: { defaultUri: defaultPath, canSelectMany: false, @@ -564,7 +566,7 @@ describe('ContextSelectionWebviewMain', () => { expect.objectContaining({ type: 'FILE_SELECTED', data: ['images/app.axf'], - for: 'image-path' + requestId: 'image-path' }) ); }); diff --git a/src/views/manage-solution/manage-solution-webview-main.ts b/src/views/manage-solution/manage-solution-webview-main.ts index 8acc02aa9..468a098b2 100644 --- a/src/views/manage-solution/manage-solution-webview-main.ts +++ b/src/views/manage-solution/manage-solution-webview-main.ts @@ -249,7 +249,7 @@ export class ManageSolutionWebviewMain { await this.updateDebuggerParameter(message.service, message.key, message.value, message.pname); break; case 'SELECT_FILE': - await this.selectFileDialog(message.targetElementId, message.options); + await this.selectFileDialog(message.requestId, message.options); break; case 'SET_AUTO_UPDATE': await this.configurationProvider.setConfigVariable(manifest.CONFIG_AUTO_DEBUG_LAUNCH, message.value, undefined, true); @@ -423,7 +423,7 @@ export class ManageSolutionWebviewMain { }); } - private async selectFileDialog(targetElementId: string, options?: FileSelectorOptionsType): Promise { + private async selectFileDialog(requestId: string, options?: FileSelectorOptionsType): Promise { const solutionDir = this.getSolutionDir(); if (options?.defaultUri) { options.defaultUri = URI.file(dirname(options.defaultUri)).toString(); @@ -447,7 +447,9 @@ export class ManageSolutionWebviewMain { ) ); - await this.webviewManager.sendMessage({ type: 'FILE_SELECTED', data: paths, for: targetElementId }); + await this.webviewManager.sendMessage({ type: 'FILE_SELECTED', data: paths, requestId }); + } else { + await this.webviewManager.sendMessage({ type: 'FILE_SELECTED', data: [], requestId }); } } diff --git a/src/views/manage-solution/messages.ts b/src/views/manage-solution/messages.ts index 18ab29fa8..cfd4c6549 100644 --- a/src/views/manage-solution/messages.ts +++ b/src/views/manage-solution/messages.ts @@ -36,7 +36,7 @@ export type OutgoingMessage | { type: 'SAVE_CONTEXT_SELECTION' } | { type: 'OPEN_HELP' } | { type: 'SET_DEBUG_ADAPTER_PROPERTY', service: string | undefined, key: string, value: string | number, pname?: string } - | { type: 'SELECT_FILE', targetElementId: string, options?: FileSelectorOptionsType } + | { type: 'SELECT_FILE', requestId: string, options?: FileSelectorOptionsType } | { type: 'SET_AUTO_UPDATE', value: boolean } | { type: 'TOGGLE_DEBUGGER', value: boolean } | { type: 'TOGGLE_DEBUG_ADAPTER_SECTION', section: string } @@ -52,6 +52,6 @@ export type IncomingMessage | { type: 'ACTIVE_TARGET_SET', data: ActiveTargetSet } | { type: 'IS_DIRTY', data: boolean } | { type: 'IS_BUSY', data: boolean } - | { type: 'FILE_SELECTED', data: string[], for: string } + | { type: 'FILE_SELECTED', data: string[], requestId: string } | { type: 'AUTO_UPDATE', data: boolean } ; diff --git a/src/views/manage-solution/view/components/manage-solution.test.tsx b/src/views/manage-solution/view/components/manage-solution.test.tsx index 3be559b77..a4147de25 100644 --- a/src/views/manage-solution/view/components/manage-solution.test.tsx +++ b/src/views/manage-solution/view/components/manage-solution.test.tsx @@ -584,37 +584,78 @@ describe('ContextSelection', () => { describe('selectFile', () => { - it('does not update input value if FILE_SELECTED message has no data', () => { + it('emits SELECT_FILE on Browse and resolves FILE_SELECTED into adapter update', () => { createContextSelectionComponent(); + postGenericDataContext(); + listener.mockClear(); - const mockInput = document.createElement('input'); - mockInput.setAttribute('id', 'test-input'); - container.appendChild(mockInput); + const filePathInput = container.querySelector('input[data-yml-node="telnet-prop_d"]') as HTMLInputElement; + expect(filePathInput).toBeDefined(); + expect(filePathInput.value).toBe(''); + + const browseButton = container.querySelector('.file-button'); + expect(browseButton).toBeDefined(); + + React.act(() => { + fireEvent.click(browseButton as Element); + }); + + const selectFileMessage = listener.mock.calls + .map(([message]) => message) + .find(message => message.type === 'SELECT_FILE'); + + expect(selectFileMessage).toBeDefined(); + expect(selectFileMessage).toEqual(expect.objectContaining({ + type: 'SELECT_FILE', + requestId: expect.stringMatching(/^manage-solution-file-/), + })); + + const selectedPath = '/path/to/selected/file.axf'; + React.act(() => { + messageHandler.postWindowMessage({ + type: 'FILE_SELECTED', + requestId: selectFileMessage!.requestId, + data: [selectedPath] + }); + }); + + expect(listener).toHaveBeenCalledWith({ + type: 'SET_DEBUG_ADAPTER_PROPERTY', + service: undefined, + key: 'telnet-prop_d', + value: selectedPath, + }); + expect(filePathInput.value).toBe(selectedPath); + }); + + it('ignores FILE_SELECTED message with empty data', () => { + createContextSelectionComponent(); + listener.mockClear(); React.act(() => { messageHandler.postWindowMessage({ type: 'FILE_SELECTED', - for: 'test-input', + requestId: 'test-input', data: [] }); }); - expect(mockInput.value).toBe(''); + expect(listener).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_DEBUG_ADAPTER_PROPERTY' })); }); - it('does not update input value if FILE_SELECTED message is for a non-existent element', () => { + it('ignores FILE_SELECTED message for unknown request id', () => { createContextSelectionComponent(); + listener.mockClear(); React.act(() => { messageHandler.postWindowMessage({ type: 'FILE_SELECTED', - for: 'non-existent-input', + requestId: 'non-existent-input', data: ['/path/to/selected/file.exe'] }); }); - const nonExistentInput = container.querySelector('#non-existent-input'); - expect(nonExistentInput).toBeNull(); + expect(listener).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'SET_DEBUG_ADAPTER_PROPERTY' })); }); }); }); diff --git a/src/views/manage-solution/view/components/manage-solution.tsx b/src/views/manage-solution/view/components/manage-solution.tsx index 593c93621..388f52f29 100644 --- a/src/views/manage-solution/view/components/manage-solution.tsx +++ b/src/views/manage-solution/view/components/manage-solution.tsx @@ -14,19 +14,18 @@ * limitations under the License. */ +import './manage-solution.css'; +import '../../../common/style/antd-overrides.css'; import { LoadingOutlined } from '@ant-design/icons'; import { Button, Checkbox, CheckboxChangeEvent, Col, ConfigProvider, Flex, Input, InputNumber, Row, Spin, Tabs, theme } from 'antd'; -import { debounce } from 'lodash'; import * as React from 'react'; import { UISection, UISectionChildren } from '../../../../debug/debug-adapters-yaml-file'; import { CompactDropdown } from '../../../common/components/compact-dropdown'; -import '../../../common/style/antd-overrides.css'; import { useVSCodeTheme } from '../../../hooks/use-vscode-theme'; import { MessageHandler } from '../../../message-handler'; import { IncomingMessage, OutgoingMessage } from '../../messages'; import { GenericPropertyList } from '../state/manage-solution-state'; import { SolutionUpdateAction, contextUpdateReducer, initialState, manageSolutionReducer } from '../state/reducer'; -import './manage-solution.css'; import { ProjectsTable } from './projects-table'; import { TargetsTable } from './targets-table'; import { PathType } from '../../types'; @@ -35,10 +34,26 @@ export interface ManageSolutionProps { messageHandler: MessageHandler; } +type PendingFileSelection = { + service: string | undefined; + key: string; + localValueKey: string; +}; + +type SelectFileContext = { + service: string | undefined; + key: string; + localValueKey: string; + title?: string; + defaultUri?: string; + pathType?: PathType; +}; + export const ManageSolution = (props: ManageSolutionProps) => { const [state, dispatch] = React.useReducer(manageSolutionReducer, initialState); // Eager local editable values snapshot (display-layer values). Numbers are stored scaled for user editing. const [localValues, setLocalValues] = React.useState>({}); + const pendingFileSelections = React.useRef>(new Map()); const adapter = React.useMemo( () => state.debugAdapters.find(adapter => adapter.name === state.debugger), @@ -74,14 +89,28 @@ export const ManageSolution = (props: ManageSolutionProps) => { React.useEffect(() => { const handleFileSelected = (message: IncomingMessage) => { - if (message.type === 'FILE_SELECTED' && message.data && message.data.length > 0) { - const element = document.getElementById(message.for || ''); - if (element) { - const ymlNode = element.getAttribute('data-yml-node') || 'config'; - props.messageHandler.push({ type: 'SET_DEBUG_ADAPTER_PROPERTY', service: undefined, key: ymlNode, value: message.data[0] }); - (element as HTMLInputElement).value = message.data[0]; - } + if (message.type !== 'FILE_SELECTED') { + return; + } + + const pendingSelection = pendingFileSelections.current.get(message.requestId); + if (!pendingSelection) { + return; + } + pendingFileSelections.current.delete(message.requestId); + + if (!message.data || message.data.length === 0) { + return; } + + const selectedPath = message.data[0]; + setLocalValues(prev => ({ ...prev, [pendingSelection.localValueKey]: selectedPath })); + props.messageHandler.push({ + type: 'SET_DEBUG_ADAPTER_PROPERTY', + service: pendingSelection.service, + key: pendingSelection.key, + value: selectedPath + }); }; const unsubscribe = props.messageHandler.subscribe(handleFileSelected); @@ -203,34 +232,26 @@ export const ManageSolution = (props: ManageSolutionProps) => { const hasDebugger = !!state.debugger; - const selectFile = React.useMemo(() => { - const handleSelectFile = (e: React.MouseEvent) => { - const input = (e.target as HTMLElement)?.parentElement?.parentNode?.parentNode?.querySelector('input'); - let id = input?.getAttribute('id'); - if (id === null || id === undefined) { - // eslint-disable-next-line react-hooks/purity - id = Math.random().toString(36).substring(2, 15); - input?.setAttribute('id', id); - } - const title = input?.getAttribute('title') || 'Select File'; - const dataOptionPathType = input?.getAttribute('data-option-path-type'); - const optionPathType: PathType = dataOptionPathType === 'absolute' ? 'absolute' : 'relative'; - const currentValue = input?.value || ''; - props.messageHandler.push({ - type: 'SELECT_FILE', - targetElementId: id, - options: { - canSelectMany: false, - defaultUri: currentValue, - openLabel: 'Select File', // title of the dialog button to choose the file - title: title, // dialog title - filters: { 'All Files': ['*'] }, - pathType: optionPathType - } - }); - }; + const selectFile = React.useCallback((context: SelectFileContext) => { + const requestId = `manage-solution-file-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + pendingFileSelections.current.set(requestId, { + service: context.service, + key: context.key, + localValueKey: context.localValueKey, + }); - return debounce(handleSelectFile, 300); + props.messageHandler.push({ + type: 'SELECT_FILE', + requestId, + options: { + canSelectMany: false, + defaultUri: context.defaultUri, + openLabel: 'Select File', + title: context.title || 'Select File', + filters: { 'All Files': ['*'] }, + pathType: context.pathType ?? 'relative' + } + }); }, [props.messageHandler]); // Eager initialization/reset of localValues whenever adapter context changes. @@ -434,7 +455,22 @@ export const ManageSolution = (props: ManageSolutionProps) => { return ( Browse} + addonAfter={ + + } value={(localValues[k] as string) ?? ''} data-yml-node={o['yml-node']} data-option-path-type={o['path-type']} diff --git a/src/views/manage-solution/view/state/reducer.test.ts b/src/views/manage-solution/view/state/reducer.test.ts index 331489feb..347c56cc8 100644 --- a/src/views/manage-solution/view/state/reducer.test.ts +++ b/src/views/manage-solution/view/state/reducer.test.ts @@ -125,7 +125,7 @@ describe('contextSelectionReducer', () => { type: 'INCOMING_MESSAGE', message: { type: 'FILE_SELECTED', data: ['C:/path/to/my/file.txt'], - for: 'some-id', + requestId: 'some-id', } });