Files
vscode/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts
T
Sandeep SomavarapuandCopilot 0a8daffbbb sessions: retain Changes details state per session (#330619)
* sessions: retain changes details state

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* sessions: rebuild stale changes tree

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-08-13 10:33:33 +00:00

289 lines
13 KiB
TypeScript

/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { Emitter, Event } from '../../../../../base/common/event.js';
import { constObservable, observableValue } from '../../../../../base/common/observable.js';
import { URI } from '../../../../../base/common/uri.js';
import { mock, upcastPartial } from '../../../../../base/test/common/mock.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js';
import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';
import { IActiveSession, ISessionsManagementService } from '../../../../services/sessions/common/sessionsManagement.js';
import { ISession, ISessionChangeset, ISessionChangesetOperation, ISessionFolder, ISessionGitRepository, ISessionWorkspace, SessionChangesetOperationScope, SessionChangesetOperationStatus } from '../../../../services/sessions/common/session.js';
import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js';
import { IAgentFeedbackService } from '../../../agentFeedback/browser/agentFeedbackService.js';
import { ICodeReviewService, PRReviewStateKind } from '../../../codeReview/browser/codeReviewService.js';
import { ChangesViewService } from '../../browser/changesViewService.js';
import { ChangesViewMode } from '../../common/changes.js';
suite('ChangesViewService', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
function createSession(id: string, options?: { readonly changesets?: readonly ISessionChangeset[]; readonly baseBranchProtected?: boolean }): IActiveSession {
const workspace = options?.baseBranchProtected === undefined
? undefined
: upcastPartial<ISessionWorkspace>({
folders: [upcastPartial<ISessionFolder>({
root: URI.file('/repo'),
name: 'repo',
gitRepository: upcastPartial<ISessionGitRepository>({
uri: URI.file('/repo'),
workTreeUri: URI.file('/repo.worktrees/session'),
baseBranchName: 'main',
baseBranchProtected: options.baseBranchProtected,
gitHubInfo: constObservable(undefined),
}),
})],
});
return upcastPartial<IActiveSession>({
resource: URI.from({ scheme: 'test-session', path: `/${id}` }),
providerId: 'local-agent-host',
sessionType: 'test',
loading: constObservable(false),
changesets: constObservable(options?.changesets ?? []),
workspace: constObservable(workspace),
});
}
function createChangeset(operations: readonly ISessionChangesetOperation[]): ISessionChangeset {
return upcastPartial<ISessionChangeset>({
id: 'branch',
isDefault: constObservable(true),
isEnabled: constObservable(true),
isLoadingChanges: constObservable(false),
operations: constObservable(operations),
changes: constObservable([]),
});
}
function createHarness(initialSession: IActiveSession, storageService = disposables.add(new TestStorageService())) {
const activeSession = observableValue<IActiveSession | undefined>('test.activeSession', initialSession);
const onDidReplaceSession = disposables.add(new Emitter<{ readonly from: ISession; readonly to: ISession }>());
const onDidDeleteSession = disposables.add(new Emitter<ISession>());
const onDidDiscardNewSession = disposables.add(new Emitter<ISession>());
const onDidReplaceNewDraftSession = disposables.add(new Emitter<{ readonly from: ISession; readonly to: ISession }>());
const sessionsService = new class extends mock<ISessionsService>() {
override readonly activeSession = activeSession;
}();
const sessionsManagementService = new class extends mock<ISessionsManagementService>() {
override readonly onDidReplaceSession = onDidReplaceSession.event;
override readonly onDidDeleteSession = onDidDeleteSession.event;
override readonly onDidDiscardNewSession = onDidDiscardNewSession.event;
override readonly onDidReplaceNewDraftSession = onDidReplaceNewDraftSession.event;
}();
const agentFeedbackService = new class extends mock<IAgentFeedbackService>() {
override readonly onDidChangeFeedback = Event.None;
override readonly activeFeedbackSessionResource = constObservable(URI.from({ scheme: 'test-feedback' }));
override getFeedback() { return []; }
}();
const codeReviewService = new class extends mock<ICodeReviewService>() {
override getPRReviewState() {
return constObservable({ kind: PRReviewStateKind.None } as const);
}
}();
const service = disposables.add(new ChangesViewService(
agentFeedbackService,
codeReviewService,
disposables.add(new MockContextKeyService()),
sessionsService,
storageService,
sessionsManagementService,
));
return { activeSession, onDidDeleteSession, onDidDiscardNewSession, onDidReplaceNewDraftSession, onDidReplaceSession, service, storageService };
}
test('restores section collapse state independently per session', () => {
const sessionA = createSession('a');
const sessionB = createSession('b');
const { activeSession, service } = createHarness(sessionA);
const states = [service.activeSessionSectionCollapseStateObs.get()];
service.setSectionCollapsed(sessionA.resource, 'otherFiles', true);
service.setSectionCollapsed(sessionA.resource, 'checks', false);
states.push(service.activeSessionSectionCollapseStateObs.get());
activeSession.set(sessionB, undefined);
states.push(service.activeSessionSectionCollapseStateObs.get());
service.setSectionCollapsed(sessionB.resource, 'checks', false);
states.push(service.activeSessionSectionCollapseStateObs.get());
activeSession.set(sessionA, undefined);
states.push(service.activeSessionSectionCollapseStateObs.get());
assert.deepStrictEqual(states, [
{ otherFiles: false, checks: true },
{ otherFiles: true, checks: false },
{ otherFiles: false, checks: true },
{ otherFiles: false, checks: false },
{ otherFiles: true, checks: false },
]);
});
test('transfers collapse state on replacement and removes it on deletion', () => {
const draft = createSession('draft');
const committed = createSession('committed');
const { activeSession, onDidDeleteSession, onDidReplaceSession, service } = createHarness(draft);
const detailsViewState = {
focus: [],
selection: [],
expanded: {},
scrollTop: 40,
};
service.setSectionCollapsed(draft.resource, 'otherFiles', true);
service.setDetailsViewState(draft.resource, ChangesViewMode.List, detailsViewState);
activeSession.set(committed, undefined);
onDidReplaceSession.fire({ from: draft, to: committed });
const afterReplacement = service.activeSessionSectionCollapseStateObs.get();
const detailsAfterReplacement = service.getDetailsViewState(committed.resource, ChangesViewMode.List);
const detailsViewStateTransfer = service.detailsViewStateTransferObs.get();
onDidDeleteSession.fire(committed);
const afterDeletion = service.activeSessionSectionCollapseStateObs.get();
const detailsAfterDeletion = service.getDetailsViewState(committed.resource, ChangesViewMode.List);
assert.deepStrictEqual({ afterReplacement, detailsAfterReplacement, detailsViewStateTransfer, afterDeletion, detailsAfterDeletion }, {
afterReplacement: { otherFiles: true, checks: true },
detailsAfterReplacement: detailsViewState,
detailsViewStateTransfer: { from: draft.resource, to: committed.resource },
afterDeletion: { otherFiles: false, checks: true },
detailsAfterDeletion: undefined,
});
});
test('removes collapse state when a draft is discarded or replaced by another draft', () => {
const firstDraft = createSession('first-draft');
const secondDraft = createSession('second-draft');
const { activeSession, onDidDiscardNewSession, onDidReplaceNewDraftSession, service } = createHarness(firstDraft);
service.setSectionCollapsed(firstDraft.resource, 'otherFiles', true);
activeSession.set(secondDraft, undefined);
onDidReplaceNewDraftSession.fire({ from: firstDraft, to: secondDraft });
const afterReplacement = service.activeSessionSectionCollapseStateObs.get();
service.setSectionCollapsed(secondDraft.resource, 'otherFiles', true);
onDidDiscardNewSession.fire(secondDraft);
const afterDiscard = service.activeSessionSectionCollapseStateObs.get();
assert.deepStrictEqual({ afterReplacement, afterDiscard }, {
afterReplacement: { otherFiles: false, checks: true },
afterDiscard: { otherFiles: false, checks: true },
});
});
test('restores details view state independently per session and view mode', () => {
const sessionA = createSession('a');
const sessionB = createSession('b');
const { service } = createHarness(sessionA);
const listState = {
focus: ['file:///repo/a.ts'],
selection: ['file:///repo/a.ts'],
expanded: {},
scrollTop: 80,
};
const treeState = {
focus: [],
selection: [],
expanded: { 'file:///repo/src': 0 as const },
scrollTop: 120,
};
service.setDetailsViewState(sessionA.resource, ChangesViewMode.List, listState);
service.setDetailsViewState(sessionA.resource, ChangesViewMode.Tree, treeState);
assert.deepStrictEqual({
sessionAList: service.getDetailsViewState(sessionA.resource, ChangesViewMode.List),
sessionATree: service.getDetailsViewState(sessionA.resource, ChangesViewMode.Tree),
sessionBList: service.getDetailsViewState(sessionB.resource, ChangesViewMode.List),
}, {
sessionAList: listState,
sessionATree: treeState,
sessionBList: undefined,
});
});
test('retains details view state for the 100 most recently used sessions', () => {
const firstSession = createSession('0');
const { service } = createHarness(firstSession);
const state = {
focus: [],
selection: [],
expanded: {},
scrollTop: 0,
};
for (let i = 0; i <= 100; i++) {
service.setDetailsViewState(createSession(`${i}`).resource, ChangesViewMode.List, state);
}
assert.deepStrictEqual({
first: service.getDetailsViewState(firstSession.resource, ChangesViewMode.List),
last: service.getDetailsViewState(createSession('100').resource, ChangesViewMode.List),
}, {
first: undefined,
last: state,
});
});
test('persists Changes view state mutations immediately', () => {
const draft = createSession('draft');
const committed = createSession('committed');
const storageService = disposables.add(new TestStorageService());
const firstHarness = createHarness(draft, storageService);
const detailsViewState = {
focus: ['file:///repo/a.ts'],
selection: ['file:///repo/a.ts'],
expanded: { 'file:///repo/src': 0 as const },
scrollTop: 64,
};
firstHarness.service.setDetailsViewState(draft.resource, ChangesViewMode.Tree, detailsViewState);
firstHarness.onDidReplaceSession.fire({ from: draft, to: committed });
firstHarness.service.dispose();
const restoredService = createHarness(committed, storageService).service;
assert.deepStrictEqual({
detailsViewState: restoredService.getDetailsViewState(committed.resource, ChangesViewMode.Tree),
draftDetailsViewState: restoredService.getDetailsViewState(draft.resource, ChangesViewMode.Tree),
}, {
detailsViewState,
draftDetailsViewState: undefined,
});
});
test('hides the Agent Host merge operation when the base branch is protected', () => {
const operations: readonly ISessionChangesetOperation[] = [
{
id: 'merge',
label: 'Merge Changes',
scopes: [SessionChangesetOperationScope.Changeset],
status: SessionChangesetOperationStatus.Idle,
},
{
id: 'create-pr',
label: 'Create PR',
scopes: [SessionChangesetOperationScope.Changeset],
status: SessionChangesetOperationStatus.Idle,
},
];
const changeset = createChangeset(operations);
const unprotected = createSession('unprotected', { changesets: [changeset], baseBranchProtected: false });
const protectedSession = createSession('protected', { changesets: [changeset], baseBranchProtected: true });
const unknown = createSession('unknown', { changesets: [changeset] });
const { activeSession, service } = createHarness(unprotected);
const visibleOperations = [service.activeSessionChangesetOperationsObs.get().map(operation => operation.id)];
activeSession.set(protectedSession, undefined);
visibleOperations.push(service.activeSessionChangesetOperationsObs.get().map(operation => operation.id));
activeSession.set(unknown, undefined);
visibleOperations.push(service.activeSessionChangesetOperationsObs.get().map(operation => operation.id));
assert.deepStrictEqual(visibleOperations, [
['merge', 'create-pr'],
['create-pr'],
['merge', 'create-pr'],
]);
});
});