Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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...'));
Expand All @@ -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
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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');
});
});

Expand Down
103 changes: 83 additions & 20 deletions src/views/manage-components-packs/components-packs-webview-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ type ManageComponentsCommandPayload = {
export class ComponentsPacksWebviewMain {
private readonly webviewManager: WebviewManager<Messages.IncomingMessage, Messages.OutgoingMessage>;

private currentProject: CurrentProject;
public static readonly WEBVIEW_COMMAND_ID = `${manifest.PACKAGE_NAME}.manageComponentsPacks`;

private project: CurrentProject;

private componentTree!: CtRoot;
private validations!: Results;
Expand Down Expand Up @@ -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);
}

Expand All @@ -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<void> {
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 },
];
Comment thread
arneschmid marked this conversation as resolved.
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 {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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() ?? '';
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -396,7 +457,7 @@ export class ComponentsPacksWebviewMain {
}

private getSolutionDir(): string {
return dirname(this.currentProject?.solutionPath ?? '');
return dirname(this.project?.solutionPath ?? '');
}

private getTargetSetData(): TargetSetData[] {
Expand Down Expand Up @@ -449,18 +510,20 @@ export class ComponentsPacksWebviewMain {
};

private async handleRequestInitialData(): Promise<void> {
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);
}
}

private async handleChangeComponentScope(message: Messages.OutgoingMessage): Promise<void> {
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<void> {
Expand All @@ -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 });
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ?? '';
}
Expand Down
Loading
Loading