diff --git a/src/views/manage-components-packs/components-packs-webview-main.test.ts b/src/views/manage-components-packs/components-packs-webview-main.test.ts index 9a6d31b9..c255a796 100644 --- a/src/views/manage-components-packs/components-packs-webview-main.test.ts +++ b/src/views/manage-components-packs/components-packs-webview-main.test.ts @@ -355,14 +355,15 @@ describe('ComponentsPacksWebviewMain', () => { jest.restoreAllMocks(); }); - it('calls debounce_load with project id and reload=true when a valid project exists', async () => { + it('calls debounce_load with project id and reload=false when selected project is unchanged', async () => { const debounceSpy = jest.spyOn(componentsPacksWebviewMain as any, 'debounce_load').mockResolvedValue(undefined); jest.spyOn(componentsPacksWebviewMain as any, 'getValidProjectId').mockReturnValue('projValid'); + jest.spyOn(componentsPacksWebviewMain as any, 'projectFromPath').mockReturnValue('sameProject'); await (componentsPacksWebviewMain as any).handleMessage({ type: 'REQUEST_INITIAL_DATA' }); expect(debounceSpy).toHaveBeenCalledTimes(1); - expect(debounceSpy).toHaveBeenCalledWith('projValid', true); + expect(debounceSpy).toHaveBeenCalledWith('projValid', false); }); it('does not call debounce_load when no valid project id exists', async () => { @@ -603,7 +604,7 @@ describe('ComponentsPacksWebviewMain', () => { 'Connecting to rpc daemon', 'Loading Packs...', 'Loading Solution...', - 'Fetching Packs Info...' + 'Retrieving assigned items...' ]) ); expect(stateMessages.indexOf('Connecting to rpc daemon')).toBeLessThan(stateMessages.indexOf('Loading Packs...')); @@ -629,6 +630,7 @@ describe('ComponentsPacksWebviewMain', () => { it('skips heavy reload steps when reload=false', async () => { const svc = setupCsolutionServiceMocks(); + (componentsPacksWebviewMain as any).usedItems = usedItemsReturn; await (componentsPacksWebviewMain as any).loadSolution('solPath', 'activeTs', 'activeCtx', false); // Heavy operations not called @@ -644,8 +646,8 @@ describe('ComponentsPacksWebviewMain', () => { const compTreeMsg = webviewManager.sendMessage.mock.calls.map(c => c[0]).find(m => m.type === 'SOLUTION_LOADED'); expect(compTreeMsg?.componentTree).toBe(componentTreeReturn); - // usedItems is set - expect((componentsPacksWebviewMain as any).usedItems).toBeDefined(); + // usedItems is not refreshed in reload=false path + expect((componentsPacksWebviewMain as any).usedItems).toBe(usedItemsReturn); }); it('handles errors and sends error messages', async () => { @@ -923,17 +925,71 @@ describe('ComponentsPacksWebviewMain', () => { expect(spy).toHaveBeenCalledWith({ newState: { solutionPath: 'sol' }, previousState: { solutionPath: '' } }); }); - it('resets cached state when panel disposes', () => { + it('resets cached state when panel disposes without unsaved changes', async () => { + jest.spyOn(componentsPacksWebviewMain as any, 'isDirty').mockResolvedValue(false); + const warningSpy = jest.spyOn(vscode.window, 'showWarningMessage'); (componentsPacksWebviewMain as any).currentProject = { solutionPath: 'sol', project: { projectId: 'proj', projectName: 'proj' } }; (componentsPacksWebviewMain as any).componentTree = { success: true, classes: [{}] }; (componentsPacksWebviewMain as any).validations = { success: true, result: 'OK', validation: [{ id: 'val' }] }; webviewManager.didDisposeEmitter.fire(); + await waitTimeout(); + expect(warningSpy).not.toHaveBeenCalled(); expect((componentsPacksWebviewMain as any).currentProject).toBeUndefined(); expect((componentsPacksWebviewMain as any).componentTree).toEqual({ success: false, classes: [] }); expect((componentsPacksWebviewMain as any).validations).toEqual({ success: false, result: 'UNDEFINED', validation: [] }); }); + + it('saves and then disposes when user picks Save on unsaved changes', async () => { + jest.spyOn(componentsPacksWebviewMain as any, 'isDirty').mockResolvedValue(true); + const applySpy = jest.spyOn(componentsPacksWebviewMain as any, 'handleApplyComponentSet').mockResolvedValue(undefined); + jest.spyOn(vscode.window, 'showWarningMessage').mockResolvedValue({ title: 'Save' } as any); + + (componentsPacksWebviewMain as any).currentProject = { solutionPath: 'sol', project: { projectId: 'proj', projectName: 'proj' } }; + (componentsPacksWebviewMain as any).usedItems = { components: [], packs: [], success: true }; + + webviewManager.didDisposeEmitter.fire(); + await waitTimeout(); + + expect(applySpy).toHaveBeenCalledTimes(1); + expect((componentsPacksWebviewMain as any).project).toBeUndefined(); + }); + + it('disposes without saving when user picks Don\'t Save on unsaved changes', async () => { + jest.spyOn(componentsPacksWebviewMain as any, 'isDirty').mockResolvedValue(true); + const applySpy = jest.spyOn(componentsPacksWebviewMain as any, 'handleApplyComponentSet').mockResolvedValue(undefined); + jest.spyOn(vscode.window, 'showWarningMessage').mockResolvedValue({ title: "Don't Save" } as any); + + (componentsPacksWebviewMain as any).currentProject = { solutionPath: 'sol', project: { projectId: 'proj', projectName: 'proj' } }; + (componentsPacksWebviewMain as any).usedItems = { components: [], packs: [], success: true }; + + webviewManager.didDisposeEmitter.fire(); + await waitTimeout(); + + expect(applySpy).not.toHaveBeenCalled(); + expect((componentsPacksWebviewMain as any).project).toBeUndefined(); + }); + + it('reopens panel and keeps state when user picks Cancel on unsaved changes', async () => { + jest.spyOn(componentsPacksWebviewMain as any, 'isDirty').mockResolvedValue(true); + const applySpy = jest.spyOn(componentsPacksWebviewMain as any, 'handleApplyComponentSet').mockResolvedValue(undefined); + const reopenSpy = jest.spyOn((componentsPacksWebviewMain as any).webviewManager, 'createOrShowPanel'); + jest.spyOn(vscode.window, 'showWarningMessage').mockResolvedValue({ title: 'Cancel' } as any); + + (componentsPacksWebviewMain as any).currentProject = { solutionPath: 'sol', project: { projectId: 'proj', projectName: 'proj' } }; + (componentsPacksWebviewMain as any).usedItems = { components: [], packs: [], success: true }; + + webviewManager.didDisposeEmitter.fire(); + await waitTimeout(); + + expect(applySpy).not.toHaveBeenCalled(); + expect(reopenSpy).toHaveBeenCalledTimes(1); + expect((componentsPacksWebviewMain as any).project).toEqual({ + solutionPath: 'sol', + project: { projectId: 'proj', projectName: 'proj' } + }); + }); }); describe('openWebview context selection', () => { @@ -1109,7 +1165,7 @@ describe('ComponentsPacksWebviewMain', () => { expect(clearSpy).toHaveBeenCalled(); expect(loadSolutionSpy).toHaveBeenCalledWith('/solutions/app.csolution.yml', 'Debug', 'ctxProj', true); expect(sendSelectedProjectSpy).toHaveBeenCalledWith('proj/board.cproject.yml'); - expect((componentsPacksWebviewMain as any).currentProject.project.projectName).toBe('board'); + expect((componentsPacksWebviewMain as any).project.project.projectName).toBe('board'); }); }); diff --git a/src/views/manage-components-packs/components-packs-webview-main.ts b/src/views/manage-components-packs/components-packs-webview-main.ts index 5828d75e..562f8570 100644 --- a/src/views/manage-components-packs/components-packs-webview-main.ts +++ b/src/views/manage-components-packs/components-packs-webview-main.ts @@ -86,7 +86,9 @@ type ManageComponentsCommandPayload = { export class ComponentsPacksWebviewMain { private readonly webviewManager: WebviewManager; - private currentProject: CurrentProject; + public static readonly WEBVIEW_COMMAND_ID = `${manifest.PACKAGE_NAME}.manageComponentsPacks`; + + private project: CurrentProject; private componentTree!: CtRoot; private validations!: Results; @@ -117,7 +119,6 @@ export class ComponentsPacksWebviewMain { new WebviewManager(context, MANAGE_COMPONENTS_WEBVIEW_OPTIONS, commandsProvider); this.manageComponentsActions.setCsolutionService(this.csolutionService); this.manageComponentsActions.setMessageProvider(this.messageProvider); - this.manageComponentsActions.setCurrentProject(this.currentProject); this.projectFileUpdater = new ProjectFileUpdaterImpl(this.solutionManager); } @@ -133,10 +134,66 @@ export class ComponentsPacksWebviewMain { } private dispose(): void { - this.currentProject = undefined; - this.componentTree = { success: false, classes: [] }; - this.validations = { success: false, result: 'UNDEFINED', validation: [] }; - this.manageComponentsActions.setCurrentProject(this.currentProject); + this.disposeInternal().catch((error) => { + // Ensure any errors during dispose do not become unhandled promise rejections. + console.error('Error during ComponentsPacksWebviewMain.dispose:', error); + }); + } + + private async disposeInternal(): Promise { + const discardView = () => { + this.currentProject = undefined; + this.componentTree = { success: false, classes: [] }; + this.validations = { success: false, result: 'UNDEFINED', validation: [] }; + this.usedItems = { components: [], packs: [], success: false }; + this.cachedTargetSetData = undefined; + this.availablePacksCache = {}; + this.unlinkRequests.clear(); + this.isLoading = false; + this.scope = ComponentScope.Solution; + }; + + const hasBaseline = this.usedItems !== undefined; + + if (hasBaseline && await this.isDirty()) { + const buttonOptions = [ + { title: 'Save' }, + { title: 'Don\'t Save' }, + { title: 'Cancel', isCloseAffordance: true }, + ]; + const messageOptions: vscode.MessageOptions = { modal: true, detail: 'Your changes will be lost if you don\'t save them.\nPress cancel to continue editing.' }; + + const pick = (await vscode.window.showWarningMessage( + 'Do you want to save the changes you made to the Solution?', + messageOptions, + ...buttonOptions, + )) || { title: 'Cancel' }; + + switch (pick.title) { + case 'Save': + await this.handleApplyComponentSet(); + discardView(); + break; + case 'Cancel': + this.webviewManager.createOrShowPanel(); + break; + case 'Don\'t Save': + default: + discardView(); + break; + } + } else { + discardView(); + } + } + + set currentProject(project: CurrentProject | undefined) { + this.project = project; + this.manageComponentsActions.setCurrentProject(project); + } + + get currentProject(): CurrentProject | undefined { + return this.project; } private resolveProjectPathFromContext(context: string): string | undefined { @@ -218,7 +275,7 @@ export class ComponentsPacksWebviewMain { if (csolution && e.newState.solutionPath) { // in case of switching a solution we need to track the correct or first project from the solution to keep this.currentProject active - if (e.newState.solutionPath !== this.currentProject?.solutionPath) { + if (e.newState.solutionPath !== this.project?.solutionPath) { this.currentProject = undefined; } @@ -320,7 +377,6 @@ export class ComponentsPacksWebviewMain { if (csolution) { this.clearTargetSetCache(); this.currentProject = { solutionPath: csolution.solutionPath, project: createProject(projectId) }; - this.manageComponentsActions.setCurrentProject(this.currentProject); const actx = this.getActiveContext(); const activeTs = csolution.getActiveTargetSetName() ?? ''; @@ -349,11 +405,16 @@ export class ComponentsPacksWebviewMain { throw new Error(`Failed loading solution: ${solutionPath} due to previous errors`); } - await this.webviewManager.sendMessage({ type: 'SET_SOLUTION_STATE', stateMessage: 'Fetching Packs Info...' }); + await this.webviewManager.sendMessage({ type: 'SET_SOLUTION_STATE', stateMessage: 'Retrieving assigned items...' }); + this.usedItems = await this.csolutionService.getUsedItems({ context: activeContext }); } - this.usedItems = await this.csolutionService.getUsedItems({ context: activeContext }); await this.webviewManager.sendMessage({ type: 'SET_UNLINKREQUESTS_STACK', unlinkRequests: Array.from(this.unlinkRequests) }); await this.sendSolutionData(); + if (reload) { + await this.sendDirtyState(); + } else { + await this.sendDirtyState({ skipApply: true }); + } } catch (error) { const messages = await this.csolutionService.getLogMessages(); @@ -396,7 +457,7 @@ export class ComponentsPacksWebviewMain { } private getSolutionDir(): string { - return dirname(this.currentProject?.solutionPath ?? ''); + return dirname(this.project?.solutionPath ?? ''); } private getTargetSetData(): TargetSetData[] { @@ -449,10 +510,12 @@ export class ComponentsPacksWebviewMain { }; private async handleRequestInitialData(): Promise { - const projectId = this.getValidProjectId(); + const cprojectPath = this.getValidProjectId(); this.scope = ComponentScope.Solution; - if (projectId) { - await this.debounce_load(projectId, true); + if (cprojectPath) { + const reload = this.projectFromPath(this.currentProject?.project.projectId) !== this.projectFromPath(cprojectPath); + + await this.debounce_load(cprojectPath, reload); } } @@ -460,7 +523,7 @@ export class ComponentsPacksWebviewMain { if (isChangeComponentScopeMessage(message)) { this.scope = message.scope; } - await this.debounce_load(this.currentProject?.project.projectId ?? '', false); + await this.debounce_load(this.project?.project.projectId ?? '', false); } private async handleApplyComponentSet(): Promise { @@ -476,7 +539,7 @@ export class ComponentsPacksWebviewMain { const state = await this.csolutionService.apply({ context: activeContext }); this.usedItems = await this.csolutionService.getUsedItems({ context: activeContext }); const usedItemsForProjectFileUpdate = cloneDeep(this.usedItems); - const projectFileName = this.currentProject?.project.projectId ?? ''; + const projectFileName = this.project?.project.projectId ?? ''; const requestAll = this.scope === ComponentScope.All; this.componentTree = this.manageComponentsActions.mapComponentsFromService(await this.csolutionService.getComponentsTree({ context: activeContext, all: requestAll })); this.validations = await this.csolutionService.validateComponents({ context: activeContext }); @@ -666,7 +729,7 @@ export class ComponentsPacksWebviewMain { const packs = await this.csolutionService.getPacksInfo({ context: actx, all: requestAll }); await this.webviewManager.sendMessage({ type: 'SET_PACKS_INFO', - packs: packsRowsFromInfo(packs, this.currentProject?.solutionPath ?? '') + packs: packsRowsFromInfo(packs, this.project?.solutionPath ?? '') }); await this.sendDirtyState(); } finally { @@ -728,7 +791,7 @@ export class ComponentsPacksWebviewMain { this.validations = await this.csolutionService.validateComponents({ context: activeContext }); const packs = packsRowsFromInfo( await this.csolutionService.getPacksInfo({ context: activeContext, all: requestAll }), - this.currentProject?.solutionPath ?? '' + this.project?.solutionPath ?? '' ); componentTreeWalker(this.componentTree, (node, type) => { @@ -831,7 +894,7 @@ export class ComponentsPacksWebviewMain { if (openExternal) { this.openFileExternal.openFile(filePath); } else { - const absoluteFilePath = path.resolve(path.dirname(this.currentProject?.solutionPath || './'), filePath); + const absoluteFilePath = path.resolve(path.dirname(this.project?.solutionPath || './'), filePath); const isMarkdown = absoluteFilePath.toLowerCase().endsWith('.md'); if (isMarkdown) { @@ -854,7 +917,7 @@ export class ComponentsPacksWebviewMain { const activeContexts = this.solutionManager.getCsolution()?.getContextDescriptors(); const currentContext = activeContexts ?.find(ctx => - normalizeForCompare(ctx.projectPath ?? '') === normalizeForCompare(this.currentProject?.project.projectId ?? '') + normalizeForCompare(ctx.projectPath ?? '') === normalizeForCompare(this.project?.project.projectId ?? '') ); return currentContext?.displayName ?? ''; } diff --git a/src/views/manage-components-packs/view/components/component-pack-manager.tsx b/src/views/manage-components-packs/view/components/component-pack-manager.tsx index f078e49a..7596ba3c 100644 --- a/src/views/manage-components-packs/view/components/component-pack-manager.tsx +++ b/src/views/manage-components-packs/view/components/component-pack-manager.tsx @@ -122,51 +122,53 @@ export const ComponentPackManager = (props: ComponentProps) => { }, token: { fontSize: 13, sizeStep: 4, borderRadius: 3 } }}> - - - - - - - - - - - - - {activeView === 'components' && - - } - {activeView === 'packs' && - - } +
+ + + + + + + + + + + + + {activeView === 'components' && + + } + {activeView === 'packs' && + + } +
diff --git a/src/views/manage-components-packs/view/components/components-view.tsx b/src/views/manage-components-packs/view/components/components-view.tsx index a03b6778..23d8c6c4 100644 --- a/src/views/manage-components-packs/view/components/components-view.tsx +++ b/src/views/manage-components-packs/view/components/components-view.tsx @@ -125,7 +125,7 @@ export const ComponentsView: React.FC = ({ { title: '', width: 40, render: (record: ComponentRowDataType) => renderWarningCell(record, state) }, Table.SELECTION_COLUMN, { title: '', width: 40, render: (record: ComponentRowDataType) => renderEditField(record, setSelectedComponent, state) }, - { title: 'Variant', dataIndex: ['parsed', 'variant'], key: 'variant', minWidth: 60, render: (value: string, record: ComponentRowDataType, index: number) => renderVariantCell(value, record, index, onChangeBundle, onChangeComponentVariant), ellipsis: false }, + { title: 'Variant', dataIndex: ['parsed', 'variant'], key: 'variant', minWidth: 60, maxWidth: 90, ellipsis: true, render: (value: string, record: ComponentRowDataType, index: number) => renderVariantCell(value, record, index, onChangeBundle, onChangeComponentVariant) }, { title: 'Version', dataIndex: ['parsed', 'version'], key: 'version', minWidth: 60, ellipsis: false }, { title: 'Vendor', dataIndex: ['parsed', 'vendor'], key: 'vendor', minWidth: 60, ellipsis: false }, { title: 'Description', dataIndex: ['data', 'description'], key: 'data.description', width: 'calc(fit-content - 50px)', ellipsis: true, render: (value: string, record: ComponentRowDataType) => renderDescriptionCell(value, record, state, openDocFile) },