Path: blob/main/src/vs/sessions/contrib/changes/browser/changesViewModel.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { Codicon } from '../../../../base/common/codicons.js';6import { arrayEqualsC, structuralEquals } from '../../../../base/common/equals.js';7import { Iterable } from '../../../../base/common/iterator.js';8import { Disposable } from '../../../../base/common/lifecycle.js';9import { derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, runOnChange, observableValue, observableSignalFromEvent, constObservable, ObservablePromise, derivedObservableWithCache } from '../../../../base/common/observable.js';10import { isWeb } from '../../../../base/common/platform.js';11import { isEqual } from '../../../../base/common/resources.js';12import { URI } from '../../../../base/common/uri.js';13import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js';14import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';15import { IChatSessionFileChange2 } from '../../../../workbench/contrib/chat/common/chatSessionsService.js';16import { GitDiffChange, IGitService } from '../../../../workbench/contrib/git/common/gitService.js';17import { COPILOT_CLOUD_SESSION_TYPE, ISessionFileChange } from '../../../services/sessions/common/session.js';18import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js';19import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js';20import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeReviewVersion, ICodeReviewService, PRReviewStateKind } from '../../codeReview/browser/codeReviewService.js';21import { IGitHubService } from '../../github/browser/githubService.js';22import { toPRContentUri } from '../../github/common/utils.js';23import { ChangesVersionMode, ChangesViewMode, IsolationMode } from '../common/changes.js';2425function toIChatSessionFileChange2(changes: GitDiffChange[], originalRef: string | undefined, modifiedRef: string | undefined): IChatSessionFileChange2[] {26return changes.map(change => ({27uri: change.uri,28originalUri: change.originalUri29? originalRef30? change.originalUri.with({ scheme: 'git', query: JSON.stringify({ path: change.originalUri.fsPath, ref: originalRef }) })31: change.originalUri32: undefined,33modifiedUri: change.modifiedUri34? modifiedRef35? change.modifiedUri.with({ scheme: 'git', query: JSON.stringify({ path: change.modifiedUri.fsPath, ref: modifiedRef }) })36: change.modifiedUri37: undefined,38insertions: change.insertions,39deletions: change.deletions,40} satisfies IChatSessionFileChange2));41}4243function sortDateDesc(dateA: Date | undefined, dateB: Date | undefined): number {44const chatALastTurnEnd = dateA?.getTime();45const chatBLastTurnEnd = dateB?.getTime();4647if (!chatALastTurnEnd && !chatBLastTurnEnd) {48return 0;49}5051if (!chatALastTurnEnd) {52return 1;53}5455if (!chatBLastTurnEnd) {56return -1;57}5859return chatBLastTurnEnd - chatALastTurnEnd;60}6162export interface ActiveSessionState {63readonly isolationMode: IsolationMode;64readonly hasGitRepository: boolean;65readonly branchName: string | undefined;66readonly baseBranchName: string | undefined;67readonly upstreamBranchName: string | undefined;68readonly isMergeBaseBranchProtected: boolean | undefined;69readonly incomingChanges: number | undefined;70readonly outgoingChanges: number | undefined;71readonly uncommittedChanges: number | undefined;72readonly hasGitHubRemote: boolean | undefined;73readonly hasPullRequest: boolean | undefined;74readonly hasOpenPullRequest: boolean | undefined;75}7677export class ChangesViewModel extends Disposable {78readonly activeSessionResourceObs: IObservable<URI | undefined>;79readonly activeSessionTypeObs: IObservable<string | undefined>;80readonly activeSessionChangesObs: IObservable<readonly ISessionFileChange[]>;81readonly activeSessionHasGitRepositoryObs: IObservable<boolean>;82readonly activeSessionFirstCheckpointRefObs: IObservable<string | undefined>;83readonly activeSessionLastCheckpointRefObs: IObservable<string | undefined>;84readonly activeSessionReviewCommentCountByFileObs: IObservable<Map<string, number>>;85readonly activeSessionAgentFeedbackCountByFileObs: IObservable<Map<string, number>>;86readonly activeSessionStateObs: IObservable<ActiveSessionState | undefined>;87readonly activeSessionIsLoadingObs: IObservable<boolean>;8889private _activeSessionMetadataObs!: IObservable<{ readonly [key: string]: unknown } | undefined>;90private _activeSessionAllChangesPromiseObs!: IObservableWithChange<IObservable<IChatSessionFileChange2[] | undefined>>;91private _activeSessionLastTurnChangesPromiseObs!: IObservableWithChange<IObservable<IChatSessionFileChange2[] | undefined>>;92private _activeSessionUncommittedChangesPromiseObs!: IObservableWithChange<IObservable<IChatSessionFileChange2[] | undefined>>;9394readonly versionModeObs: ISettableObservable<ChangesVersionMode>;95setVersionMode(mode: ChangesVersionMode): void {96if (this.versionModeObs.get() === mode) {97return;98}99this.versionModeObs.set(mode, undefined);100}101102readonly viewModeObs: ISettableObservable<ChangesViewMode>;103setViewMode(mode: ChangesViewMode): void {104if (this.viewModeObs.get() === mode) {105return;106}107this.viewModeObs.set(mode, undefined);108this.storageService.store('changesView.viewMode', mode, StorageScope.WORKSPACE, StorageTarget.USER);109}110111constructor(112@IAgentFeedbackService private readonly agentFeedbackService: IAgentFeedbackService,113@IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService,114@ICodeReviewService private readonly codeReviewService: ICodeReviewService,115@IGitHubService private readonly gitHubService: IGitHubService,116@IGitService private readonly gitService: IGitService,117@ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService,118@IStorageService private readonly storageService: IStorageService,119) {120super();121122// Active session resource123this.activeSessionResourceObs = derivedOpts({ equalsFn: isEqual }, reader => {124const activeSession = this.sessionManagementService.activeSession.read(reader);125return activeSession?.resource;126});127128// Active session type129this.activeSessionTypeObs = derived(reader => {130const activeSession = this.sessionManagementService.activeSession.read(reader);131return activeSession?.sessionType;132});133134// Active session metadata135this._activeSessionMetadataObs = this._getActiveSessionMetadata();136137// Active session has git repository138this.activeSessionHasGitRepositoryObs = derived(reader => {139const sessionType = this.activeSessionTypeObs.read(reader);140const metadata = this._activeSessionMetadataObs.read(reader);141if (sessionType === COPILOT_CLOUD_SESSION_TYPE || metadata?.repositoryPath !== undefined) {142return true;143}144145// Fall back to reading details from repo on the session management service session146const activeSession = this.sessionManagementService.activeSession.read(reader);147const workspace = activeSession?.workspace.read(reader);148const repository = workspace?.repositories[0];149return repository !== undefined && (150repository.uncommittedChanges !== undefined ||151repository.incomingChanges !== undefined ||152repository.outgoingChanges !== undefined ||153repository.upstreamBranchName !== undefined154);155});156157// Active session first checkpoint ref158this.activeSessionFirstCheckpointRefObs = derived(reader => {159const metadata = this._activeSessionMetadataObs.read(reader);160return metadata?.firstCheckpointRef as string | undefined;161});162163// Active session last checkpoint ref164this.activeSessionLastCheckpointRefObs = derived(reader => {165const activeSessionChats = this.sessionManagementService.activeSession.read(reader)?.chats.read(reader);166if (!activeSessionChats || activeSessionChats.length === 0) {167return undefined;168}169170// Session has only one chat171if (activeSessionChats.length === 1) {172const metadata = this._activeSessionMetadataObs.read(reader);173return metadata?.lastCheckpointRef as string | undefined;174}175176// Session has multiple chats - find the last chat that completed177const chatsSortedByLastTurnEnd = activeSessionChats.toSorted((chatA, chatB) => {178const chatALastTurnEnd = chatA.lastTurnEnd.read(reader);179const chatBLastTurnEnd = chatB.lastTurnEnd.read(reader);180181return sortDateDesc(chatALastTurnEnd, chatBLastTurnEnd);182});183184const model = this.agentSessionsService.getSession(chatsSortedByLastTurnEnd[0].resource);185return model?.metadata?.lastCheckpointRef as string | undefined;186});187188// Active session state189const { isLoading, state } = this._getActiveSessionState();190this.activeSessionIsLoadingObs = isLoading;191this.activeSessionStateObs = state;192193// Active session changes194this.activeSessionChangesObs = this._getActiveSessionChanges();195196// Active session review comment count by file197this.activeSessionReviewCommentCountByFileObs = this._getActiveSessionReviewComments();198199// Active session agent feedback count by file200this.activeSessionAgentFeedbackCountByFileObs = this._getActiveSessionAgentFeedback();201202// Version mode203this.versionModeObs = observableValue<ChangesVersionMode>(this, ChangesVersionMode.BranchChanges);204205this._register(runOnChange(this.activeSessionResourceObs, () => {206this.setVersionMode(ChangesVersionMode.BranchChanges);207}));208209// View mode210const storedMode = this.storageService.get('changesView.viewMode', StorageScope.WORKSPACE);211const initialMode = storedMode === ChangesViewMode.Tree ? ChangesViewMode.Tree : ChangesViewMode.List;212this.viewModeObs = observableValue<ChangesViewMode>(this, initialMode);213}214215private _getActiveSessionMetadata(): IObservable<{ readonly [key: string]: unknown } | undefined> {216const sessionsChangedSignal = observableSignalFromEvent(this,217this.sessionManagementService.onDidChangeSessions);218219const sessionMetadata = derivedObservableWithCache<{ readonly [key: string]: unknown } | undefined>(this, (reader, lastValue) => {220const sessionResource = this.activeSessionResourceObs.read(reader);221if (!sessionResource) {222return undefined;223}224225sessionsChangedSignal.read(reader);226const model = this.agentSessionsService.getSession(sessionResource);227if (model === undefined) {228// This occurs when the untitled session is committed. In order229// to avoid flickering of the toolbar, we keep the old metadata230// until the new metadata is available.231return lastValue;232}233234return model.metadata;235});236237return derivedOpts<{ readonly [key: string]: unknown } | undefined>({ equalsFn: structuralEquals }, reader => {238return sessionMetadata.read(reader);239});240}241242private _getActiveSessionChanges(): IObservable<readonly ISessionFileChange[]> {243// Changes244const activeSessionChangesObs = derived(reader => {245const activeSession = this.sessionManagementService.activeSession.read(reader);246if (!activeSession) {247return Iterable.empty();248}249return activeSession.changes.read(reader);250});251252const activeSessionRepositoryPathObs = derived(reader => {253const metadata = this._activeSessionMetadataObs.read(reader);254const repositoryPath = metadata?.repositoryPath as string | undefined;255const worktreePath = metadata?.worktreePath as string | undefined;256257return worktreePath ?? repositoryPath;258});259260// Uncommitted changes261const activeSessionUncommittedChangesCountObs = derived(reader => {262const sessionMetadata = this._activeSessionMetadataObs.read(reader);263const uncommittedChanges = sessionMetadata?.uncommittedChanges as number | undefined;264265const activeSession = this.sessionManagementService.activeSession.read(reader);266const workspace = activeSession?.workspace.read(reader);267const workspaceRepository = workspace?.repositories[0];268269return uncommittedChanges ?? workspaceRepository?.uncommittedChanges;270});271272this._activeSessionUncommittedChangesPromiseObs = derived(reader => {273const repositoryPath = activeSessionRepositoryPathObs.read(reader);274if (!repositoryPath) {275return constObservable([]);276}277278// Re-run when the number of uncommitted changes changes279activeSessionUncommittedChangesCountObs.read(reader);280281const diffPromise = this._getRepositoryChanges(repositoryPath, 'HEAD', undefined);282return new ObservablePromise(diffPromise).resolvedValue;283});284285// All changes286this._activeSessionAllChangesPromiseObs = derived(reader => {287const sessionType = this.activeSessionTypeObs.read(reader);288289if (sessionType === COPILOT_CLOUD_SESSION_TYPE) {290// Cloud session291const metadata = this._activeSessionMetadataObs.read(reader);292293const firstCheckpointRef = metadata?.baseRefOid as string | undefined;294const lastCheckpointRef = metadata?.headRefOid as string | undefined;295296if (!firstCheckpointRef || !lastCheckpointRef) {297return constObservable([]);298}299300const diffPromise = this._getPullRequestChanges(firstCheckpointRef, lastCheckpointRef);301return new ObservablePromise(diffPromise).resolvedValue;302}303304// Local session305const repositoryPath = activeSessionRepositoryPathObs.read(reader);306const firstCheckpointRef = this.activeSessionFirstCheckpointRefObs.read(reader);307const lastCheckpointRef = this.activeSessionLastCheckpointRefObs.read(reader);308309if (!repositoryPath || !firstCheckpointRef || !lastCheckpointRef) {310return constObservable([]);311}312313const diffPromise = this._getRepositoryChanges(repositoryPath, firstCheckpointRef, lastCheckpointRef);314return new ObservablePromise(diffPromise).resolvedValue;315});316317// Last turn changes318this._activeSessionLastTurnChangesPromiseObs = derived(reader => {319const sessionType = this.activeSessionTypeObs.read(reader);320321if (sessionType === COPILOT_CLOUD_SESSION_TYPE) {322// Cloud session323const metadata = this._activeSessionMetadataObs.read(reader);324const lastCheckpointRef = metadata?.headRefOid as string | undefined;325326if (!lastCheckpointRef) {327return constObservable([]);328}329330const diffPromise = this._getPullRequestChanges(`${lastCheckpointRef}^`, lastCheckpointRef);331return new ObservablePromise(diffPromise).resolvedValue;332}333334// Local session335const repositoryPath = activeSessionRepositoryPathObs.read(reader);336const lastCheckpointRef = this.activeSessionLastCheckpointRefObs.read(reader);337338if (!repositoryPath || !lastCheckpointRef) {339return constObservable([]);340}341342const diffPromise = this._getRepositoryChanges(repositoryPath, `${lastCheckpointRef}^`, lastCheckpointRef);343return new ObservablePromise(diffPromise).resolvedValue;344});345346return derivedOpts({347equalsFn: arrayEqualsC<ISessionFileChange>()348}, reader => {349const versionMode = this.versionModeObs.read(reader);350351// BranchChanges reads from the session provider's `changes`352// observable directly (e.g. agent-host-tracked diffs), so it353// works even for sessions without a git repository.354if (versionMode === ChangesVersionMode.BranchChanges) {355return activeSessionChangesObs.read(reader);356}357358const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader);359if (!hasGitRepository && !isWeb) {360return [];361}362363if (versionMode === ChangesVersionMode.UncommittedChanges) {364return this._activeSessionUncommittedChangesPromiseObs.read(reader).read(reader) ?? [];365} else if (versionMode === ChangesVersionMode.AllChanges) {366return this._activeSessionAllChangesPromiseObs.read(reader).read(reader) ?? [];367} else if (versionMode === ChangesVersionMode.LastTurn) {368return this._activeSessionLastTurnChangesPromiseObs.read(reader).read(reader) ?? [];369}370371return [];372});373}374375private _getActiveSessionState(): { isLoading: IObservable<boolean>; state: IObservable<ActiveSessionState | undefined> } {376const isLoadingObs = derived(reader => {377// Branch changes378const versionMode = this.versionModeObs.read(reader);379if (versionMode === ChangesVersionMode.BranchChanges) {380return false;381}382383// Uncommitted changes384if (versionMode === ChangesVersionMode.UncommittedChanges) {385const uncommittedChangesResult = this._activeSessionUncommittedChangesPromiseObs.read(reader).read(reader);386return uncommittedChangesResult === undefined;387}388389// All changes390if (versionMode === ChangesVersionMode.AllChanges) {391const allChangesResult = this._activeSessionAllChangesPromiseObs.read(reader).read(reader);392return allChangesResult === undefined;393}394395// Last turn changes396if (versionMode === ChangesVersionMode.LastTurn) {397const lastTurnChangesResult = this._activeSessionLastTurnChangesPromiseObs.read(reader).read(reader);398return lastTurnChangesResult === undefined;399}400401return false;402});403404const activeSessionStateObs = derivedObservableWithCache<ActiveSessionState | undefined>(this, (reader, lastValue) => {405const isLoading = isLoadingObs.read(reader);406if (isLoading) {407return lastValue;408}409410const sessionMetadata = this._activeSessionMetadataObs.read(reader);411const activeSession = this.sessionManagementService.activeSession.read(reader);412const workspace = activeSession?.workspace.read(reader);413414// Session state415const workspaceRepository = workspace?.repositories[0];416const hasGitRepository = this.activeSessionHasGitRepositoryObs.read(reader);417const branchName = (sessionMetadata?.branchName ?? sessionMetadata?.branch) as string | undefined418?? workspaceRepository?.branchName;419const baseBranchName = (sessionMetadata?.baseBranchName ?? sessionMetadata?.baseBranch) as string | undefined420?? workspaceRepository?.baseBranchName;421422// Fall back to reading details from repo on the session management service session423const isMergeBaseBranchProtected = (sessionMetadata?.baseBranchProtected as boolean | undefined)424?? workspaceRepository?.baseBranchProtected;425const isolationMode = workspaceRepository?.workingDirectory === undefined426? IsolationMode.Workspace427: IsolationMode.Worktree;428429// Pull request state430const gitHubInfo = activeSession?.gitHubInfo.read(reader);431const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined;432const hasOpenPullRequest = hasPullRequest &&433(gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id ||434gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id);435436// Fall back to reading details from repo on the session management service session437const hasGitHubRemote = (sessionMetadata?.hasGitHubRemote as boolean | undefined) ?? workspaceRepository?.hasGitHubRemote ?? false;438const upstreamBranchName = (sessionMetadata?.upstreamBranchName as string | undefined) ?? workspaceRepository?.upstreamBranchName;439const incomingChanges = (sessionMetadata?.incomingChanges as number | undefined) ?? workspaceRepository?.incomingChanges ?? 0;440const outgoingChanges = (sessionMetadata?.outgoingChanges as number | undefined) ?? workspaceRepository?.outgoingChanges ?? 0;441const uncommittedChanges = (sessionMetadata?.uncommittedChanges as number | undefined) ?? workspaceRepository?.uncommittedChanges ?? 0;442443return {444isolationMode,445hasGitRepository,446branchName,447baseBranchName,448isMergeBaseBranchProtected,449upstreamBranchName,450incomingChanges,451outgoingChanges,452uncommittedChanges,453hasGitHubRemote,454hasPullRequest,455hasOpenPullRequest456} satisfies ActiveSessionState;457});458459return {460isLoading: isLoadingObs,461state: derivedOpts({ equalsFn: structuralEquals },462reader => activeSessionStateObs.read(reader))463};464}465466private _getActiveSessionReviewComments(): IObservable<Map<string, number>> {467return derived(reader => {468const sessionResource = this.activeSessionResourceObs.read(reader);469const changes = [...this.activeSessionChangesObs.read(reader)];470471if (!sessionResource) {472return new Map<string, number>();473}474475const result = new Map<string, number>();476const prReviewState = this.codeReviewService.getPRReviewState(sessionResource).read(reader);477if (prReviewState.kind === PRReviewStateKind.Loaded) {478for (const comment of prReviewState.comments) {479const uriKey = comment.uri.fsPath;480result.set(uriKey, (result.get(uriKey) ?? 0) + 1);481}482}483484if (changes.length === 0) {485return result;486}487488const reviewFiles = getCodeReviewFilesFromSessionChanges(changes);489const reviewVersion = getCodeReviewVersion(reviewFiles);490const reviewState = this.codeReviewService.getReviewState(sessionResource).read(reader);491492if (reviewState.kind !== CodeReviewStateKind.Result || reviewState.version !== reviewVersion) {493return result;494}495496for (const comment of reviewState.comments) {497const uriKey = comment.uri.fsPath;498result.set(uriKey, (result.get(uriKey) ?? 0) + 1);499}500501return result;502});503}504505private _getActiveSessionAgentFeedback(): IObservable<Map<string, number>> {506return derived(reader => {507const sessionResource = this.activeSessionResourceObs.read(reader);508if (!sessionResource) {509return new Map<string, number>();510}511512observableSignalFromEvent(this, this.agentFeedbackService.onDidChangeFeedback).read(reader);513514const feedbackItems = this.agentFeedbackService.getFeedback(sessionResource);515const result = new Map<string, number>();516for (const item of feedbackItems) {517if (!item.sourcePRReviewCommentId) {518const uriKey = item.resourceUri.fsPath;519result.set(uriKey, (result.get(uriKey) ?? 0) + 1);520}521}522return result;523});524}525526private async _getRepositoryChanges(repositoryPath: string, firstCheckpointRef: string, lastCheckpointRef: string | undefined): Promise<IChatSessionFileChange2[] | undefined> {527const repository = await this.gitService.openRepository(URI.file(repositoryPath));528const ref = lastCheckpointRef529? `${firstCheckpointRef}..${lastCheckpointRef}`530: firstCheckpointRef;531532const changes = await repository?.diffBetweenWithStats2(ref) ?? [];533return toIChatSessionFileChange2(changes, firstCheckpointRef, lastCheckpointRef);534}535536private async _getPullRequestChanges(firstCheckpointRef: string, lastCheckpointRef: string): Promise<IChatSessionFileChange2[] | undefined> {537const gitHubInfo = this.sessionManagementService.activeSession.get()?.gitHubInfo.get();538if (!gitHubInfo?.owner || !gitHubInfo?.repo || !gitHubInfo?.pullRequest?.number) {539return [];540}541542const params = {543owner: gitHubInfo.owner,544repo: gitHubInfo.repo,545prNumber: gitHubInfo.pullRequest.number,546} as const;547548const changes = await this.gitHubService.getChangedFiles(params.owner, params.repo, firstCheckpointRef, lastCheckpointRef);549return changes.map(change => {550const uri = toPRContentUri(change.filename, {551...params,552commitSha: lastCheckpointRef,553status: change.status,554isBase: false555});556557const originalUri = change.status !== 'added'558? toPRContentUri(change.previous_filename || change.filename, {559...params,560commitSha: firstCheckpointRef,561previousFileName: change.previous_filename,562status: change.status,563isBase: true564})565: undefined;566567const modifiedUri = change.status !== 'removed'568? uri569: undefined;570571return {572uri,573originalUri,574modifiedUri,575insertions: change.additions,576deletions: change.deletions577} satisfies IChatSessionFileChange2;578});579}580}581582583