From 0a8daffbbb71c0a6c72bcfddcacda7f92fc5e742 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Thu, 13 Aug 2026 12:33:33 +0200 Subject: [PATCH] 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> --- .github/skills/sessions/SKILL.md | 3 + .../contrib/changes/browser/changesView.ts | 90 +++++++++++++++- .../changes/browser/changesViewService.ts | 94 +++++++++++++--- .../changes/common/changesViewService.ts | 15 +++ .../test/browser/changesViewService.test.ts | 102 +++++++++++++++++- .../sessions/changesView.fixture.ts | 7 +- 6 files changed, 289 insertions(+), 22 deletions(-) diff --git a/.github/skills/sessions/SKILL.md b/.github/skills/sessions/SKILL.md index 6a791078d72..d4888ab45aa 100644 --- a/.github/skills/sessions/SKILL.md +++ b/.github/skills/sessions/SKILL.md @@ -136,6 +136,9 @@ Then read the relevant spec for the area you are changing (see table below). If - **`ChatSource` is fully discriminated**: Fork and side-chat sources both require explicit `kind` plus stable top-level `turnId`. Do not add no-kind compatibility helpers or route by structural property presence; switch directly on `source.kind`. - **Sessions menu ids must live in the shared menu registry**: Do not declare sessions-owned `new MenuId(...)` constants ad hoc inside individual parts. Add them to `browser/menus.ts` under `Menus` with discoverable `SessionsEditor...` names so ownership and reuse stay obvious. - **Events instead of observables**: Session state must flow through `IObservable`, not `Event`. Use `autorun`/`derived` for reactive UI, not `onDid*` event listeners. +- **Keep feature-specific ObjectTree restoration local**: data trees such as Explorer accept complete view state through `setInput`, but `ObjectTree.setChildren` does not. Do not widen the shared tree widget API for one Sessions consumer; apply saved collapse state to the replacement children, then restore focus, selection, and scroll from identities in the feature. +- **Bound retained Changes-details state like editor view state**: keep per-session details snapshots in a 100-entry LRU, move them on draft commit, and clear them only on definitive delete/discard events. Provider `removed` deltas are temporary eviction signals and must not discard durable UI state. +- **Do not persist a view snapshot through an independently registered shutdown listener**: service and view-container `onWillSaveState` listener order can save the service before the view captures its final state. Since the Changes tree is sampled only at lifecycle boundaries, persist synchronously when that sample updates the service, and likewise persist replacement/deletion mutations immediately. - **Importing from providers**: Non-provider `contrib/*` code must never import from `contrib/providers/*`. Extract shared interfaces to `services/` or `common/`. - **`IAgentSessionsService` in shared code**: `IAgentSessionsService` (`vs/workbench/contrib/chat/browser/agentSessions/agentSessionsService`) is a Copilot-provider internal and may be imported **only** by the Copilot chat sessions provider (`contrib/providers/copilotChatSessions/`). Shared sessions code (core/services/non-provider contribs, e.g. the sessions list or visible-sessions grid) must stay provider-agnostic and go through `ISession`/`ISessionsManagementService` — never reach into `model.observeSession(...)` etc. for lazy loading. This is enforced by an ESLint `no-restricted-imports` ban scoped to `src/vs/sessions/**` (Copilot provider exempted). - **Missing entry point import**: New contribution files must be imported in the appropriate `sessions.*.main.ts` entry point to be loaded (for example `sessions.common.main.ts`, `sessions.desktop.main.ts`, `sessions.web.main.ts`, or `sessions.web.main.internal.ts`). diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index 6e2496c84d6..80233b7601b 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -87,7 +87,7 @@ import { compareFileNames, comparePaths } from '../../../../base/common/comparer import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IMarkdownString } from '../../../../base/common/htmlContent.js'; -import { ChangesViewSection, IChangesViewService } from '../common/changesViewService.js'; +import { ChangesViewSection, IChangesDetailsViewState, IChangesDetailsViewStateTransfer, IChangesViewService } from '../common/changesViewService.js'; import { ChangesSummaryWidget } from './changesSummaryWidget.js'; import { Menus } from '../../../browser/menus.js'; import { IAgentWorkbenchLayoutService } from '../../../browser/workbench.js'; @@ -535,6 +535,8 @@ export class ChangesViewPane extends ViewPane { private changesProgressBar!: ProgressBar; private tree: WorkbenchCompressibleObjectTree | undefined; + private renderedTreeState: { readonly sessionResource: URI; readonly viewMode: ChangesViewMode } | undefined; + private detailsViewStateTransfer: IChangesDetailsViewStateTransfer | undefined; private ciStatusWidget: CIStatusWidget | undefined; private sessionFilesWidget: SessionFilesWidget | undefined; private splitView: SplitView | undefined; @@ -811,6 +813,7 @@ export class ChangesViewPane extends ViewPane { if (visible) { this.onVisible(); } else { + this.captureDetailsViewState(); this.renderDisposables.clear(); } })); @@ -979,15 +982,38 @@ export class ChangesViewPane extends ViewPane { this.renderDisposables.add(autorun(reader => { const changes = changesObs.read(reader); const viewMode = this.changesViewService.viewModeObs.read(reader); - const changesetLoading = this.changesViewService.activeSessionChangesetLoadingObs.read(reader); + const activeSessionLoading = this.changesViewService.activeSessionLoadingObs.read(reader); + const sessionResource = this.changesViewService.activeSessionResourceObs.read(reader); // Read session state so this autorun re-runs when git state (e.g. branch // name) arrives asynchronously, since the tree root label depends on it. this.changesViewService.activeSessionStateObs.read(reader); - if (!this.tree || changesetLoading) { + if (!this.tree || activeSessionLoading) { return; } + const detailsViewStateTransfer = this.changesViewService.detailsViewStateTransferObs.read(reader); + if (detailsViewStateTransfer !== this.detailsViewStateTransfer) { + this.detailsViewStateTransfer = detailsViewStateTransfer; + if (detailsViewStateTransfer && this.renderedTreeState) { + const renderedSessionResource = this.renderedTreeState.sessionResource; + if (isEqual(renderedSessionResource, detailsViewStateTransfer.from)) { + this.captureDetailsViewState(detailsViewStateTransfer.to); + this.renderedTreeState = undefined; + if (sessionResource && isEqual(sessionResource, detailsViewStateTransfer.from)) { + return; + } + } else if (!isEqual(renderedSessionResource, detailsViewStateTransfer.to)) { + this.captureDetailsViewState(); + if (sessionResource && isEqual(sessionResource, renderedSessionResource)) { + return; + } + } + } + } else { + this.captureDetailsViewState(); + } + const detailsViewState = sessionResource ? this.changesViewService.getDetailsViewState(sessionResource, viewMode) : undefined; // Toggle list-mode class to remove tree indentation in list mode this.listContainer?.classList.toggle('list-mode', viewMode === ChangesViewMode.List); @@ -996,14 +1022,14 @@ export class ChangesViewPane extends ViewPane { // Tree mode: build hierarchical tree from file entries const treeRootInfo = this.getTreeRootInfo(changes); const treeChildren = buildTreeChildren(changes, treeRootInfo); - this.tree.setChildren(null, treeChildren); + this.setDetailsTreeChildren(sessionResource, viewMode, detailsViewState, treeChildren); } else { // List mode: flat list of file items const listChildren = changes.map(item => ({ element: item, collapsible: false, } satisfies IObjectTreeElement)); - this.tree.setChildren(null, listChildren); + this.setDetailsTreeChildren(sessionResource, viewMode, detailsViewState, listChildren); } this.fireTreePaneSizeChange(); @@ -1011,6 +1037,60 @@ export class ChangesViewPane extends ViewPane { })); } + override saveState(): void { + this.captureDetailsViewState(); + super.saveState(); + } + + private captureDetailsViewState(sessionResource?: URI): void { + if (!this.tree || !this.renderedTreeState) { + return; + } + + const state = this.tree.getViewState().toJSON(); + this.changesViewService.setDetailsViewState(sessionResource ?? this.renderedTreeState.sessionResource, this.renderedTreeState.viewMode, { + ...state, + focus: Array.from(state.focus), + selection: Array.from(state.selection), + }); + } + + private setDetailsTreeChildren(sessionResource: URI | undefined, viewMode: ChangesViewMode, state: IChangesDetailsViewState | undefined, children: readonly IObjectTreeElement[]): void { + if (!this.tree) { + return; + } + + const elementsById = new Map(); + const restoredChildren = this.applyDetailsViewState(children, state, elementsById); + + this.renderedTreeState = undefined; + this.tree.setChildren(null, restoredChildren); + this.tree.setFocus(state ? Array.from(state.focus, id => elementsById.get(id)).filter(element => element !== undefined) : []); + this.tree.setSelection(state ? Array.from(state.selection, id => elementsById.get(id)).filter(element => element !== undefined) : []); + this.tree.scrollTop = state?.scrollTop ?? 0; + this.renderedTreeState = sessionResource ? { sessionResource, viewMode } : undefined; + } + + private applyDetailsViewState( + children: readonly IObjectTreeElement[], + state: IChangesDetailsViewState | undefined, + elementsById: Map, + ): IObjectTreeElement[] { + return children.map(child => { + const id = child.element.uri.toString(); + elementsById.set(id, child.element); + const restoredChildren = child.children + ? this.applyDetailsViewState(Array.from(child.children), state, elementsById) + : undefined; + const expanded = state?.expanded[id]; + return { + ...child, + children: restoredChildren, + collapsed: expanded === undefined ? child.collapsed : expanded === 0, + }; + }); + } + private _bindContextKeys(topLevelStats: IObservable<{ files: number } | undefined>): void { // Request in progress (can be updated independently since it only affects action enablement, and not visibility) this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => { diff --git a/src/vs/sessions/contrib/changes/browser/changesViewService.ts b/src/vs/sessions/contrib/changes/browser/changesViewService.ts index 2e5787b5ddc..2548b46f383 100644 --- a/src/vs/sessions/contrib/changes/browser/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/browser/changesViewService.ts @@ -6,7 +6,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { structuralEquals } from '../../../../base/common/equals.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; -import { ResourceMap } from '../../../../base/common/map.js'; +import { LRUCache, ResourceMap } from '../../../../base/common/map.js'; import { autorun, derived, derivedObservableWithCache, derivedOpts, IObservable, ISettableObservable, observableSignal, observableSignalFromEvent, observableValue } from '../../../../base/common/observable.js'; import { isEqual } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; @@ -20,7 +20,7 @@ import { ISessionsManagementService } from '../../../services/sessions/common/se import { AgentFeedbackState, IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; import { ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js'; import { ChangesViewMode, IsolationMode } from '../common/changes.js'; -import { ActiveSessionState, ChangesViewSection, IChangesViewSectionCollapseState, IChangesViewService } from '../common/changesViewService.js'; +import { ActiveSessionState, ChangesViewSection, IChangesDetailsViewState, IChangesDetailsViewStateTransfer, IChangesViewSectionCollapseState, IChangesViewService } from '../common/changesViewService.js'; export const ChangesetReviewSupportContext = new RawContextKey('sessions.changesetReviewSupport', false); export const ChangesetReviewedFilesContext = new RawContextKey('sessions.changesetReviewedFiles', []); @@ -31,6 +31,14 @@ const DEFAULT_SECTION_COLLAPSE_STATE: IChangesViewSectionCollapseState = Object. checks: true, }); +interface IStoredChangesViewState { + readonly sessionResource: string; + readonly detailsViewState?: Partial>; +} + +const SESSION_VIEW_STATE_STORAGE_KEY = 'changesView.sessionViewState'; +const SESSION_VIEW_STATE_LIMIT = 100; + export class ChangesViewService extends Disposable implements IChangesViewService { declare readonly _serviceBrand: undefined; @@ -53,6 +61,8 @@ export class ChangesViewService extends Disposable implements IChangesViewServic private readonly _sectionCollapseStateBySession = new ResourceMap(); private readonly _sectionCollapseStateChanged = observableSignal('changesView.sectionCollapseStateChanged'); + private readonly _detailsViewStateBySession = new LRUCache>>(SESSION_VIEW_STATE_LIMIT); + readonly detailsViewStateTransferObs = observableValue(this, undefined); private readonly _selectedChangesetId = observableValue(this, undefined); setChangesetId(changesetId: string | undefined): void { @@ -78,6 +88,7 @@ export class ChangesViewService extends Disposable implements IChangesViewServic @ISessionsManagementService sessionsManagementService: ISessionsManagementService, ) { super(); + this._loadViewState(); // Active session resource this.activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => { @@ -208,19 +219,26 @@ export class ChangesViewService extends Disposable implements IChangesViewServic this.setChangesetId(undefined); })); this._register(sessionsManagementService.onDidReplaceSession(({ from, to }) => { - const state = this._sectionCollapseStateBySession.get(from.resource); - if (!state) { - return; + const sectionCollapseState = this._sectionCollapseStateBySession.get(from.resource); + if (sectionCollapseState) { + this._sectionCollapseStateBySession.delete(from.resource); + this._sectionCollapseStateBySession.set(to.resource, sectionCollapseState); + this._sectionCollapseStateChanged.trigger(undefined); } - this._sectionCollapseStateBySession.delete(from.resource); - this._sectionCollapseStateBySession.set(to.resource, state); - this._sectionCollapseStateChanged.trigger(undefined); + + const detailsViewState = this._detailsViewStateBySession.get(from.resource.toString()); + if (detailsViewState) { + this._detailsViewStateBySession.delete(from.resource.toString()); + this._detailsViewStateBySession.set(to.resource.toString(), detailsViewState); + this._saveViewState(); + } + this.detailsViewStateTransferObs.set({ from: from.resource, to: to.resource }, undefined); })); this._register(sessionsManagementService.onDidDeleteSession(session => { - this._deleteSectionCollapseState(session.resource); + this._deleteSessionViewState(session.resource); })); - this._register(sessionsManagementService.onDidDiscardNewSession(session => this._deleteSectionCollapseState(session.resource))); - this._register(sessionsManagementService.onDidReplaceNewDraftSession(({ from }) => this._deleteSectionCollapseState(from.resource))); + this._register(sessionsManagementService.onDidDiscardNewSession(session => this._deleteSessionViewState(session.resource))); + this._register(sessionsManagementService.onDidReplaceNewDraftSession(({ from }) => this._deleteSessionViewState(from.resource))); // Global context keys this._bindContextKeys(); @@ -241,10 +259,62 @@ export class ChangesViewService extends Disposable implements IChangesViewServic this._sectionCollapseStateChanged.trigger(undefined); } - private _deleteSectionCollapseState(sessionResource: URI): void { + getDetailsViewState(sessionResource: URI, viewMode: ChangesViewMode): IChangesDetailsViewState | undefined { + return this._detailsViewStateBySession.get(sessionResource.toString())?.[viewMode]; + } + + setDetailsViewState(sessionResource: URI, viewMode: ChangesViewMode, state: IChangesDetailsViewState): void { + const key = sessionResource.toString(); + const current = this._detailsViewStateBySession.get(key); + if (structuralEquals(current?.[viewMode], state)) { + return; + } + this._detailsViewStateBySession.set(key, { ...current, [viewMode]: state }); + this._saveViewState(); + } + + private _deleteSessionViewState(sessionResource: URI): void { if (this._sectionCollapseStateBySession.delete(sessionResource)) { this._sectionCollapseStateChanged.trigger(undefined); } + if (this._detailsViewStateBySession.delete(sessionResource.toString())) { + this._saveViewState(); + } + } + + private _loadViewState(): void { + const entries = this.storageService.getObject(SESSION_VIEW_STATE_STORAGE_KEY, StorageScope.WORKSPACE, []); + if (!Array.isArray(entries)) { + this.storageService.remove(SESSION_VIEW_STATE_STORAGE_KEY, StorageScope.WORKSPACE); + return; + } + + for (const entry of entries) { + if (typeof entry.sessionResource !== 'string') { + continue; + } + + const resource = URI.parse(entry.sessionResource); + if (entry.detailsViewState) { + this._detailsViewStateBySession.set(resource.toString(), entry.detailsViewState); + } + } + } + + private _saveViewState(): void { + if (this._detailsViewStateBySession.size === 0) { + this.storageService.remove(SESSION_VIEW_STATE_STORAGE_KEY, StorageScope.WORKSPACE); + return; + } + + const entries: IStoredChangesViewState[] = []; + this._detailsViewStateBySession.forEach((detailsViewState, sessionResource) => { + entries.push({ + sessionResource, + detailsViewState, + }); + }); + this.storageService.store(SESSION_VIEW_STATE_STORAGE_KEY, JSON.stringify(entries), StorageScope.WORKSPACE, StorageTarget.MACHINE); } setChangesetFilesReviewState(resources: readonly URI[], reviewed: boolean): void { diff --git a/src/vs/sessions/contrib/changes/common/changesViewService.ts b/src/vs/sessions/contrib/changes/common/changesViewService.ts index da8aae1db80..cf82ffc84ac 100644 --- a/src/vs/sessions/contrib/changes/common/changesViewService.ts +++ b/src/vs/sessions/contrib/changes/common/changesViewService.ts @@ -35,6 +35,18 @@ export interface IChangesViewSectionCollapseState { export type ChangesViewSection = keyof IChangesViewSectionCollapseState; +export interface IChangesDetailsViewState { + readonly focus: readonly string[]; + readonly selection: readonly string[]; + readonly expanded: Readonly>; + readonly scrollTop: number; +} + +export interface IChangesDetailsViewStateTransfer { + readonly from: URI; + readonly to: URI; +} + export interface IChangesViewService { readonly _serviceBrand: undefined; @@ -59,6 +71,9 @@ export interface IChangesViewService { readonly viewModeObs: IObservable; setViewMode(mode: ChangesViewMode): void; setSectionCollapsed(sessionResource: URI, section: ChangesViewSection, collapsed: boolean): void; + readonly detailsViewStateTransferObs: IObservable; + getDetailsViewState(sessionResource: URI, viewMode: ChangesViewMode): IChangesDetailsViewState | undefined; + setDetailsViewState(sessionResource: URI, viewMode: ChangesViewMode, state: IChangesDetailsViewState): void; setChangesetFilesReviewState(resources: readonly URI[], reviewed: boolean): void; } diff --git a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts index e1483982fef..b24a585cb79 100644 --- a/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts +++ b/src/vs/sessions/contrib/changes/test/browser/changesViewService.test.ts @@ -17,6 +17,7 @@ import { ISessionsService } from '../../../../services/sessions/browser/sessions 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', () => { @@ -59,7 +60,7 @@ suite('ChangesViewService', () => { }); } - function createHarness(initialSession: IActiveSession) { + function createHarness(initialSession: IActiveSession, storageService = disposables.add(new TestStorageService())) { const activeSession = observableValue('test.activeSession', initialSession); const onDidReplaceSession = disposables.add(new Emitter<{ readonly from: ISession; readonly to: ISession }>()); const onDidDeleteSession = disposables.add(new Emitter()); @@ -89,11 +90,11 @@ suite('ChangesViewService', () => { codeReviewService, disposables.add(new MockContextKeyService()), sessionsService, - disposables.add(new TestStorageService()), + storageService, sessionsManagementService, )); - return { activeSession, onDidDeleteSession, onDidDiscardNewSession, onDidReplaceNewDraftSession, onDidReplaceSession, service }; + return { activeSession, onDidDeleteSession, onDidDiscardNewSession, onDidReplaceNewDraftSession, onDidReplaceSession, service, storageService }; } test('restores section collapse state independently per session', () => { @@ -125,17 +126,30 @@ suite('ChangesViewService', () => { 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, afterDeletion }, { + 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, }); }); @@ -158,6 +172,86 @@ suite('ChangesViewService', () => { }); }); + 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[] = [ { diff --git a/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts index d4f7d93c614..3459c6abafc 100644 --- a/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/sessions/changesView.fixture.ts @@ -32,7 +32,7 @@ import { FixtureMenuService } from '../chat/chatFixtureUtils.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../fixtureUtils.js'; // eslint-disable-next-line local/code-import-patterns -import { ActiveSessionState, ChangesViewSection, IChangesViewSectionCollapseState, IChangesViewService } from '../../../../../sessions/contrib/changes/common/changesViewService.js'; +import { ActiveSessionState, ChangesViewSection, IChangesDetailsViewState, IChangesViewSectionCollapseState, IChangesViewService } from '../../../../../sessions/contrib/changes/common/changesViewService.js'; // eslint-disable-next-line local/code-import-patterns import { CHANGES_VIEW_CONTAINER_ID, CHANGES_VIEW_ID, ChangesViewMode, IsolationMode } from '../../../../../sessions/contrib/changes/common/changes.js'; // eslint-disable-next-line local/code-import-patterns @@ -85,6 +85,7 @@ class FixtureChangesViewService extends Disposable implements IChangesViewServic readonly activeSessionStateObs: IObservable; readonly activeSessionLoadingObs: IObservable; readonly activeSessionSectionCollapseStateObs: IObservable; + readonly detailsViewStateTransferObs = constObservable(undefined); readonly viewModeObs = observableValue(this, ChangesViewMode.List); constructor(session: IActiveSession, options: IChangesViewFixtureOptions) { @@ -126,6 +127,10 @@ class FixtureChangesViewService extends Disposable implements IChangesViewServic setSectionCollapsed(_sessionResource: URI, _section: ChangesViewSection, _collapsed: boolean): void { } + getDetailsViewState(_sessionResource: URI, _viewMode: ChangesViewMode): IChangesDetailsViewState | undefined { return undefined; } + + setDetailsViewState(_sessionResource: URI, _viewMode: ChangesViewMode, _state: IChangesDetailsViewState): void { } + setChangesetId(_changesetId: string | undefined): void { } setChangesetFilesReviewState(_resources: readonly URI[], _reviewed: boolean): void { }