diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts index e38c94ebdff..0f8a4e2bada 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionMetadataStore.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import type * as vscode from 'vscode'; -import type { Uri } from 'vscode'; import { createServiceIdentifier } from '../../../util/common/services'; import { ChatSessionWorktreeProperties } from './chatSessionWorktreeService'; import type { IWorkspaceInfo } from './workspaceInfo'; @@ -118,9 +117,7 @@ export interface IChatSessionMetadataStore { storeWorkspaceFolderInfo(sessionId: string, entry: WorkspaceFolderEntry): Promise; storeRepositoryProperties(sessionId: string, properties: RepositoryProperties): Promise; getRepositoryProperties(sessionId: string): Promise; - getSessionIdForWorktree(folder: vscode.Uri): Promise; getWorktreeProperties(sessionId: string): Promise; - getWorktreeProperties(folder: Uri): Promise; getSessionWorkspaceFolder(sessionId: string): Promise; getSessionWorkspaceFolderEntry(sessionId: string): Promise; getAdditionalWorkspaces(sessionId: string): Promise; @@ -147,4 +144,13 @@ export interface IChatSessionMetadataStore { * on demand. Concurrent calls collapse: at most one in-flight + one pending. */ refresh(): Promise; + /** + * Returns session IDs whose working directory (worktree path or workspace folder) + * matches the given folder URI. + */ + getSessionIdsForFolder(folder: vscode.Uri): string[]; + /** + * Returns session IDs that have a worktree whose path matches the given folder URI. + */ + getWorktreeSessions(folder: vscode.Uri): string[]; } diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts index e26a50bf2c4..ea4c702b72f 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts @@ -63,7 +63,6 @@ export interface IChatSessionWorktreeService { createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise; getWorktreeProperties(sessionId: string): Promise; - getWorktreeProperties(folder: vscode.Uri): Promise; setWorktreeProperties(sessionId: string, properties: string | ChatSessionWorktreeProperties): Promise; getWorktreeRepository(sessionId: string): Promise; @@ -71,8 +70,6 @@ export interface IChatSessionWorktreeService { applyWorktreeChanges(sessionId: string): Promise; - getSessionIdForWorktree(folder: vscode.Uri): Promise; - getWorktreeChanges(sessionId: string): Promise; hasCachedChanges(sessionId: string): Promise; diff --git a/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts b/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts index 96aa2ee811e..ed442e5a4e3 100644 --- a/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts +++ b/extensions/copilot/src/extension/chatSessions/common/test/mockChatSessionMetadataStore.ts @@ -54,11 +54,8 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore { return undefined; } - async getWorktreeProperties(sessionIdOrFolder: string | vscode.Uri): Promise { - if (typeof sessionIdOrFolder === 'string') { - return this._worktreeProperties.get(sessionIdOrFolder); - } - return undefined; + async getWorktreeProperties(sessionId: string): Promise { + return this._worktreeProperties.get(sessionId); } async getSessionWorkspaceFolder(_sessionId: string): Promise { @@ -155,4 +152,31 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore { getSessionParentId(_sessionId: string): Promise { return Promise.resolve(undefined); } + + getSessionIdsForFolder(folder: vscode.Uri): string[] { + const folderPath = folder.fsPath; + const sessionIds: string[] = []; + for (const [sessionId, props] of this._worktreeProperties) { + if (props.worktreePath === folderPath) { + sessionIds.push(sessionId); + } + } + for (const [sessionId, entry] of this._workspaceFolders) { + if (entry.folderPath === folderPath && !sessionIds.includes(sessionId)) { + sessionIds.push(sessionId); + } + } + return sessionIds; + } + + getWorktreeSessions(folder: vscode.Uri): string[] { + const folderPath = folder.fsPath; + const sessionIds: string[] = []; + for (const [sessionId, props] of this._worktreeProperties) { + if (props.worktreePath === folderPath) { + sessionIds.push(sessionId); + } + } + return sessionIds; + } } diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts index ea9074ae5d3..f8a612d248b 100644 --- a/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/node/cliHelpers.ts @@ -44,13 +44,3 @@ export function getCopilotCLIWorkspaceFile(sessionId: string) { export function getCopilotBulkMetadataFile(): string { return join(getCopilotHome(), 'vscode.session.metadata.cache.json'); } - -/** - * Path of the shared worktree-sessions JSONL index. Append-only, one - * {@link WorktreeSessionEntry} per line. - * Used as a worktree folder → session-id fallback - * when an entry has been evicted from the bulk cache. - */ -export function getCopilotWorktreeSessionsFile(): string { - return join(getCopilotHome(), 'vscode.session.worktree.jsonl'); -} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts index fc5790906f7..5c3b1c02af2 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts @@ -11,22 +11,19 @@ import { ILogService } from '../../../platform/log/common/logService'; import { findLast } from '../../../util/vs/base/common/arraysFind'; import { SequencerByKey, ThrottledDelayer } from '../../../util/vs/base/common/async'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; -import { dirname, isEqual } from '../../../util/vs/base/common/resources'; -import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry, WorktreeSessionEntry } from '../common/chatSessionMetadataStore'; +import { dirname } from '../../../util/vs/base/common/resources'; +import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore'; import { ChatSessionWorktreeProperties } from '../common/chatSessionWorktreeService'; import { isUntitledSessionId } from '../common/utils'; import { IWorkspaceInfo } from '../common/workspaceInfo'; -import { getCopilotBulkMetadataFile, getCopilotCLISessionDir, getCopilotCLISessionStateDir, getCopilotWorktreeSessionsFile } from '../copilotcli/node/cliHelpers'; +import { getCopilotBulkMetadataFile, getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; import { ICopilotCLIAgents } from '../copilotcli/node/copilotCli'; -import { WorktreeSessionIndex } from './worktreeSessionIndex'; // const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders'; // const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees'; const LEGACY_BULK_METADATA_FILENAME = 'copilotcli.session.metadata.json'; const LEGACY_BULK_MIGRATED_KEY = 'github.copilot.cli.legacyBulkMigrated'; -const JSONL_SCAN_DONE_KEY = 'github.copilot.cli.events.jsonl.scaned'; const REQUEST_MAPPING_FILENAME = 'vscode.requests.metadata.json'; -const SESSION_SCAN_BATCH_SIZE = 20; /** * Maximum number of sessions kept in the shared bulk metadata cache file @@ -49,8 +46,10 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession */ private _cache: Record = {}; - /** Maps session id → JSONL entry and folder path → session id. Owns JSONL file persistence. */ - private readonly _worktreeSessions: WorktreeSessionIndex; + /** Session ID → indexed path and kind, for reverse-lookup cleanup. */ + private readonly _sessionFolderEntry = new Map(); + /** Folder path → set of session IDs (worktree path or workspace folder path). */ + private readonly _folderToSessions = new Map>(); /** Path of the shared bulk metadata cache file in `~/.copilot/`. */ private readonly _cacheFile = Uri.file(getCopilotBulkMetadataFile()); @@ -77,12 +76,6 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession ) { super(); - this._worktreeSessions = new WorktreeSessionIndex( - this.fileSystemService, - this.logService, - getCopilotWorktreeSessionsFile(), - ); - this._ready = this.initializeStorage(); this._ready.catch(error => { this.logService.error('[ChatSessionMetadataStore] Initialization failed: ', error); @@ -97,6 +90,65 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession return this._ready; } + public getSessionIdsForFolder(folder: vscode.Uri): string[] { + return Array.from(this._folderToSessions.get(folder.fsPath) ?? []); + } + + public getWorktreeSessions(folder: vscode.Uri): string[] { + const sessions = this._folderToSessions.get(folder.fsPath); + if (!sessions) { + return []; + } + const result: string[] = []; + for (const sessionId of sessions) { + if (this._sessionFolderEntry.get(sessionId)?.kind === 'worktree') { + result.push(sessionId); + } + } + return result; + } + + /** + * Maintains {@link _sessionFolderEntry} and {@link _folderToSessions} so + * that {@link getSessionIdsForFolder} and {@link getWorktreeSessions} + * are O(1) lookups instead of full-cache scans. + */ + private _updateFolderIndex(sessionId: string, metadata: ChatSessionMetadataFile | undefined): void { + // Remove old entry + const old = this._sessionFolderEntry.get(sessionId); + if (old) { + const set = this._folderToSessions.get(old.path); + if (set) { + set.delete(sessionId); + if (set.size === 0) { + this._folderToSessions.delete(old.path); + } + } + this._sessionFolderEntry.delete(sessionId); + } + + if (!metadata) { + return; + } + + // Prefer worktree path over workspace folder path + const worktreePath = metadata.worktreeProperties?.worktreePath; + const folderPath = metadata.workspaceFolder?.folderPath; + const path = worktreePath ?? folderPath; + if (!path) { + return; + } + + const kind: 'worktree' | 'folder' = worktreePath ? 'worktree' : 'folder'; + this._sessionFolderEntry.set(sessionId, { path, kind }); + let set = this._folderToSessions.get(path); + if (!set) { + set = new Set(); + this._folderToSessions.set(path, set); + } + set.add(sessionId); + } + private async initializeStorage(): Promise { // One-time migration from the legacy per-install bulk file in // globalStorageUri to the shared `~/.copilot/` location. @@ -115,14 +167,13 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession } } + // Build folder index from the cleaned cache. + for (const [sessionId, metadata] of Object.entries(this._cache)) { + this._updateFolderIndex(sessionId, metadata); + } + // this.extensionContext.globalState.update(WORKTREE_MEMENTO_KEY, undefined); // this.extensionContext.globalState.update(WORKSPACE_FOLDER_MEMENTO_KEY, undefined); - - - // Ensure every cached session with a worktreePath has a JSONL - // entry. Only appends entries that are missing; falls back to a full rewrite when - // the load detected duplicates or malformed lines. - await this.topUpJsonlIndexFromCache(); } public getMetadataFileUri(sessionId: string): vscode.Uri { @@ -137,13 +188,13 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession await this._ready; if (sessionId in this._cache) { delete this._cache[sessionId]; + this._updateFolderIndex(sessionId, undefined); const data = await this.getGlobalStorageData().catch(() => ({} as Record)); delete data[sessionId]; await this.writeToGlobalStorage(data); } try { await Promise.allSettled([ - this._worktreeSessions.removeAndWriteToDisk(sessionId), this.fileSystemService.delete(this.getMetadataFileUri(sessionId)), this.fileSystemService.delete(this.getRequestMappingFileUri(sessionId)) ]); @@ -163,6 +214,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession // cannot stomp fields written by other processes (Step 3b: stale-cache fix). const existing = this._cache[sessionId] ?? {}; this._cache[sessionId] = { ...existing, ...fields }; + this._updateFolderIndex(sessionId, this._cache[sessionId]); await this.updateSessionMetadata(sessionId, fields); this.updateGlobalStorage(); } @@ -184,47 +236,10 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession return metadata?.repositoryProperties; } - getWorktreeProperties(sessionId: string): Promise; - getWorktreeProperties(folder: Uri): Promise; - async getWorktreeProperties(sessionId: string | Uri): Promise { + async getWorktreeProperties(sessionId: string): Promise { await this._ready; - if (typeof sessionId === 'string') { - const metadata = await this.getSessionMetadata(sessionId); - return metadata?.worktreeProperties; - } - const folder = sessionId; - // First check the in-memory cache. - for (const metadata of Object.values(this._cache)) { - if (metadata.worktreeProperties?.worktreePath && isEqual(Uri.file(metadata.worktreeProperties.worktreePath), folder)) { - return metadata.worktreeProperties; - } - } - // Fallback to the JSONL worktree index → hydrate from the per-session file. - const id = await this.findSessionIdForWorktree(folder); - if (id) { - const metadata = await this.getSessionMetadata(id); - return metadata?.worktreeProperties; - } - return undefined; - } - async getSessionIdForWorktree(folder: vscode.Uri): Promise { - await this._ready; - for (const [sessionId, value] of Object.entries(this._cache)) { - if (value.worktreeProperties?.worktreePath && isEqual(vscode.Uri.file(value.worktreeProperties.worktreePath), folder)) { - return sessionId; - } - } - return this.findSessionIdForWorktree(folder); - } - - /** Looks up a session id for a worktree folder via the JSONL index, with a throttled disk reload. */ - private async findSessionIdForWorktree(folder: vscode.Uri): Promise { - const cached = this._worktreeSessions.getSessionIdForFolder(folder); - if (cached) { - return cached; - } - await this._worktreeSessions.reloadIfStale(); - return this._worktreeSessions.getSessionIdForFolder(folder); + const metadata = await this.getSessionMetadata(sessionId); + return metadata?.worktreeProperties; } async getSessionWorkspaceFolder(sessionId: string): Promise { @@ -397,6 +412,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession const metadata = await this.readSessionMetadataFile(sessionId); if (metadata) { this._cache[sessionId] = metadata; + this._updateFolderIndex(sessionId, metadata); return metadata; } @@ -446,6 +462,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession if (!createDirectoryIfNotFound) { // Lets not delete the session from our storage, but mark it as written to session state so that we won't try to write to session state again and again. this._cache[sessionId] = { ...updates, writtenToDisc: true }; + this._updateFolderIndex(sessionId, this._cache[sessionId]); this.updateGlobalStorage(); return; } @@ -475,31 +492,11 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession merged.created = now; } - const promises: Promise[] = []; - - // Maintain the JSONL worktree index based on the post-merge worktreePath: - // - new entry → append a line and remember it - // - changed path → rewrite the file (rare) - // - cleared path → remove via rewrite - const worktreePath = merged.worktreeProperties?.worktreePath; - const indexed = this._worktreeSessions.getSessionEntry(sessionId); - if (worktreePath) { - if (!indexed) { - promises.push(this._worktreeSessions.appendBatchToDisk([{ id: sessionId, path: worktreePath, created: merged.created }])); - } else if (indexed.path !== worktreePath && !merged.kind) { - this._worktreeSessions.addEntry({ id: sessionId, path: worktreePath, created: indexed.created }); - promises.push(this._worktreeSessions.writeToDisk()); - } - } else if (indexed) { - promises.push(this._worktreeSessions.removeAndWriteToDisk(sessionId)); - } - const content = new TextEncoder().encode(JSON.stringify(merged, null, 2)); - promises.push(this.fileSystemService.writeFile(fileUri, content)); - - await Promise.all(promises); + await this.fileSystemService.writeFile(fileUri, content); this._cache[sessionId] = { ...merged, writtenToDisc: true }; + this._updateFolderIndex(sessionId, this._cache[sessionId]); this.updateGlobalStorage(); this.logService.trace(`[ChatSessionMetadataStore] Wrote metadata for session ${sessionId}`); }); @@ -529,6 +526,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession if (!local) { data[sessionId] = diskEntry; this._cache[sessionId] = diskEntry; + this._updateFolderIndex(sessionId, diskEntry); continue; } const localModified = local.modified ?? 0; @@ -536,6 +534,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession if (diskModified > localModified) { data[sessionId] = diskEntry; this._cache[sessionId] = diskEntry; + this._updateFolderIndex(sessionId, diskEntry); } } } catch { @@ -591,12 +590,14 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession const local = this._cache[id]; if (!local) { this._cache[id] = diskEntry; + this._updateFolderIndex(id, diskEntry); continue; } const localModified = local.modified ?? 0; const diskModified = diskEntry.modified ?? 0; if (diskModified > localModified) { this._cache[id] = diskEntry; + this._updateFolderIndex(id, diskEntry); } } }); @@ -668,106 +669,4 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession this.logService.error('[ChatSessionMetadataStore] Failed to migrate legacy bulk file: ', err); } } - - /** - * For every cached session with a `worktreePath`, ensure a JSONL entry exists. - */ - private async topUpJsonlIndexFromCache(): Promise { - // Load the JSONL worktree index from disk first so the scan below can - // tell which entries already exist and avoid re-appending duplicates. - let { rewriteNeeded } = await this._worktreeSessions.loadFromDisk(); - - const toAppend: WorktreeSessionEntry[] = []; - for (const [id, metadata] of Object.entries(this._cache)) { - const path = metadata.worktreeProperties?.worktreePath; - if (!path || metadata.kind) { - continue; - } - const existing = this._worktreeSessions.getSessionEntry(id); - if (existing && existing.path === path) { - continue; - } - const entry: WorktreeSessionEntry = { id, path, created: existing?.created ?? metadata.created ?? Date.now() }; - this._worktreeSessions.addEntry(entry); - if (existing) { - // Path changed — a full rewrite is needed. - rewriteNeeded = true; - } else { - toAppend.push(entry); - } - } - - if (rewriteNeeded) { - await this._worktreeSessions.writeToDisk(); - } else if (toAppend.length > 0) { - await this._worktreeSessions.appendBatchToDisk(toAppend); - } - - // One-time full scan of ~/.copilot/session-state/ to discover worktree - // sessions that were never recorded in the JSONL (e.g. sessions created - // before the JSONL index existed, or evicted from the bulk cache). - await this.scanSessionStateDirForWorktrees(); - } - - /** - * One-time scan of `~/.copilot/session-state/` to discover worktree sessions - * not yet in the JSONL index. Reads per-session metadata files in batches of - * {@link SESSION_SCAN_BATCH_SIZE} to avoid saturating I/O. Gated by a memento - * so it only runs once per install. - */ - private async scanSessionStateDirForWorktrees(): Promise { - if (this.extensionContext.globalState.get(JSONL_SCAN_DONE_KEY)) { - return; - } - - const sessionStateDir = Uri.file(getCopilotCLISessionStateDir()); - let entries: [string, number][]; - try { - entries = await this.fileSystemService.readDirectory(sessionStateDir); - } catch { - // Directory doesn't exist — nothing to scan. - await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true); - return; - } - - // Collect session IDs we don't already know about. - const unknownIds: string[] = []; - for (const [name] of entries) { - if (name in this._cache || this._worktreeSessions.has(name)) { - continue; - } - unknownIds.push(name); - } - - if (unknownIds.length === 0) { - await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true); - return; - } - - // Read metadata files in batches. - let discovered = false; - for (let i = 0; i < unknownIds.length; i += SESSION_SCAN_BATCH_SIZE) { - const batch = unknownIds.slice(i, i + SESSION_SCAN_BATCH_SIZE); - const results = await Promise.all(batch.map(async id => { - const metadata = await this.readSessionMetadataFile(id); - return { id, metadata }; - })); - for (const { id, metadata } of results) { - if (!metadata?.worktreeProperties?.worktreePath || metadata.kind) { - continue; - } - const path = metadata.worktreeProperties.worktreePath; - if (!this._worktreeSessions.has(id)) { - this._worktreeSessions.addEntry({ id, path, created: metadata.created ?? Date.now() }); - discovered = true; - } - } - } - - if (discovered) { - await this._worktreeSessions.writeToDisk(); - } - await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true); - this.logService.info(`[ChatSessionMetadataStore] Session-state scan complete: checked ${unknownIds.length} unknown session(s)`); - } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts index 01e7e8eb522..7bed0b04222 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts @@ -10,6 +10,7 @@ import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspa import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions'; import { IGitService } from '../../../platform/git/common/gitService'; +import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; export class ChatSessionRepositoryTracker extends Disposable { private readonly repositories = new DisposableResourceMap(); @@ -19,7 +20,8 @@ export class ChatSessionRepositoryTracker extends Disposable { @IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService, @IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService, @IGitService private readonly gitService: IGitService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore ) { super(); @@ -69,33 +71,21 @@ export class ChatSessionRepositoryTracker extends Disposable { private async onDidChangeRepositoryState(uri: vscode.Uri): Promise { this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Repository state changed for ${uri.toString()}. Updating session properties.`); - const worktreeSessionId = await this.worktreeService.getSessionIdForWorktree(uri); + const sessionIds = await this.metadataStore.getSessionIdsForFolder(uri); const workspaceSessionIds = this.workspaceFolderService.clearWorkspaceChanges(uri); - - if (worktreeSessionId) { + sessionIds.push(...workspaceSessionIds); + await Promise.all(Array.from(new Set(sessionIds)).map(async sessionId => { // Worktree - const worktreeProperties = await this.worktreeService.getWorktreeProperties(worktreeSessionId); - if (!worktreeProperties) { - return; + const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId); + if (worktreeProperties) { + await this.worktreeService.setWorktreeProperties(sessionId, { + ...worktreeProperties, + changes: undefined + }); } - - await this.worktreeService.setWorktreeProperties(worktreeSessionId, { - ...worktreeProperties, - changes: undefined - }); - - await this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: worktreeSessionId }); - this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${uri.toString()}.`); - } else if (workspaceSessionIds.length > 0) { - // Workspace - // This is still using the old ChatSessionItem API so there is no need to refresh each session - // associated with the workspace folder. When the new controller API is fully adopted we will - // have to refresh each session. - await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds: workspaceSessionIds }); - this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for workspace ${uri.toString()}.`); - } else { - this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] No session associated with workspace ${uri.toString()}.`); - } + })); + await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds }); + this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${uri.toString()}.`); } private disposeRepositoryWatcher(uri: vscode.Uri): void { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index 5c2f599121f..715ef295eea 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -18,7 +18,6 @@ import { ILogService } from '../../../platform/log/common/logService'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import * as path from '../../../util/vs/base/common/path'; -import { isEqual } from '../../../util/vs/base/common/resources'; import { generateUuid } from '../../../util/vs/base/common/uuid'; import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace'; import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; @@ -178,28 +177,13 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi return branch; } - getWorktreeProperties(sessionId: string): Promise; - getWorktreeProperties(folder: vscode.Uri): Promise; - async getWorktreeProperties(sessionIdOrFolder: string | vscode.Uri): Promise { - if (typeof sessionIdOrFolder === 'string') { - const properties = this._sessionWorktrees.get(sessionIdOrFolder); - if (properties !== undefined) { - return typeof properties === 'string' ? undefined : properties; - } - // Fall back to metadata store (file-based) - return this.metadataStore.getWorktreeProperties(sessionIdOrFolder); - } else { - for (const [_, value] of this._sessionWorktrees.entries()) { - if (typeof value === 'string') { - continue; - } - if (isEqual(vscode.Uri.file(value.worktreePath), sessionIdOrFolder)) { - return value; - } - } - // Fall back to metadata store (file-based) - return this.metadataStore.getWorktreeProperties(sessionIdOrFolder); + async getWorktreeProperties(sessionId: string): Promise { + const properties = this._sessionWorktrees.get(sessionId); + if (properties !== undefined) { + return typeof properties === 'string' ? undefined : properties; } + // Fall back to metadata store (file-based) + return this.metadataStore.getWorktreeProperties(sessionId); } async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise { @@ -397,18 +381,6 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } } - async getSessionIdForWorktree(folder: vscode.Uri): Promise { - for (const [sessionId, value] of this._sessionWorktrees.entries()) { - if (typeof value === 'string') { - continue; - } - if (isEqual(vscode.Uri.file(value.worktreePath), folder)) { - return sessionId; - } - } - return this.metadataStore.getSessionIdForWorktree(folder); - } - async handleRequestCompleted(sessionId: string): Promise { const worktreeProperties = await this.getWorktreeProperties(sessionId); if (!worktreeProperties) { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 3b678188f73..08ed2b1fd1f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -131,7 +131,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements private readonly controller: vscode.ChatSessionItemController; private readonly newSessions = new ResourceMap(); - constructor( @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, @IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService, @@ -308,6 +307,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements this._register(this._workspaceService.onDidChangeWorkspaceFolders(refreshActiveInputState)); } + public getAssociatedSessions(folder: Uri): string[] { + return this._metadataStore.getSessionIdsForFolder(folder); + } + public async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise { await this._optionGroupBuilder.rebuildInputState(inputState, folderUri); } @@ -341,7 +344,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements let worktreeProperties = await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id), token); const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath) : session.workingDirectory; - if (token.isCancellationRequested) { return item; @@ -352,7 +354,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements // `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the // eager pass and let `resolveChatSessionItem` fill it in lazily for visible items. // But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass. - if (options?.includeChanges || ((await this.canBuildChangesFast(session.id, worktreeProperties)))) { + if (options?.includeChanges || ((await this.hasCachedChanges(session.id, worktreeProperties)))) { const changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token); if (token.isCancellationRequested) { return item; @@ -407,19 +409,15 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements return badge; } - private async canBuildChangesFast(sessionId: string, worktreeProperties: Awaited>): Promise { + private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited>): Promise { if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) { return true; } - if (!worktreeProperties?.repositoryPath) { - return false; - } - const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([ - vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)), + const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([ this.copilotCLIWorktreeManagerService.hasCachedChanges(sessionId), this._workspaceFolderService.hasCachedChanges(sessionId) ]); - return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges); + return hasCachedWorktreeChanges || hasCachedWorkspaceChanges; } private async buildChanges( @@ -1700,21 +1698,19 @@ export function registerCLIChatCommands( logService.trace('[commitToWorktree] Commit successful'); // Clear the worktree changes cache so getWorktreeChanges() recomputes - const sessionId = await copilotCLIWorktreeManagerService.getSessionIdForWorktree(worktreeUri); - if (sessionId) { + const sessionIds = await contentProvider.getAssociatedSessions(worktreeUri); + await Promise.all(sessionIds.map(async sessionId => { const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); if (props) { await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined }); } else { logService.error('[commitToWorktree] No worktree properties found for session:', sessionId); } - } else { - logService.error('[commitToWorktree] No session found for worktree:', worktreeUri.toString()); - } + })); logService.trace('[commitToWorktree] Notifying sessions change'); - if (sessionId) { - await contentProvider.refreshSession({ reason: 'update', sessionId }); + if (sessionIds.length) { + await contentProvider.refreshSession({ reason: 'update', sessionIds }); } } catch (error) { const { stdout = '', stderr = '', gitErrorCode } = error as { stdout?: string; stderr?: string; gitErrorCode?: string }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 62c6e2bb764..b9bec5c4577 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -171,7 +171,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event; - public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise; constructor( @@ -217,6 +216,10 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc })); } + public getAssociatedSessions(folder: Uri): string[] { + return this.chatSessionMetadataStore.getSessionIdsForFolder(folder); + } + /** * We should remove this or move this to CopilotCLISessionService */ @@ -297,7 +300,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc // eager pass and let `resolveChatSessionItem` fill it in lazily for visible items. // But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass. let changes: vscode.ChatSessionChangedFile[] | undefined; - if (!token.isCancellationRequested && (options?.includeChanges || (await this.canBuildChangesFast(session.id, worktreeProperties)))) { + if (!token.isCancellationRequested && (options?.includeChanges || (await this.hasCachedChanges(session.id, worktreeProperties)))) { changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token); // We need to get an updated version of worktree properties here because when the @@ -406,19 +409,15 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } satisfies vscode.ChatSessionItem; } - private async canBuildChangesFast(sessionId: string, worktreeProperties: Awaited>): Promise { + private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited>): Promise { if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) { return true; } - if (!worktreeProperties?.repositoryPath) { - return false; - } - const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([ - vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)), + const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([ this.worktreeManager.hasCachedChanges(sessionId), this.workspaceFolderService.hasCachedChanges(sessionId) ]); - return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges); + return hasCachedWorktreeChanges || hasCachedWorkspaceChanges; } @@ -2727,17 +2726,15 @@ export function registerCLIChatCommands( logService.trace('[commitToWorktree] Commit successful'); // Clear the worktree changes cache so getWorktreeChanges() recomputes - const sessionId = await copilotCLIWorktreeManagerService.getSessionIdForWorktree(worktreeUri); - if (sessionId) { + const sessionIds = await copilotcliSessionItemProvider.getAssociatedSessions(worktreeUri); + await Promise.all(sessionIds.map(async sessionId => { const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); if (props) { await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined }); } else { logService.error('[commitToWorktree] No worktree properties found for session:', sessionId); } - } else { - logService.error('[commitToWorktree] No session found for worktree:', worktreeUri.toString()); - } + })); logService.trace('[commitToWorktree] Notifying sessions change'); copilotcliSessionItemProvider.notifySessionsChange(); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts index 6c1e857cf1c..3ada694c252 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/folderRepositoryManagerImpl.ts @@ -16,7 +16,7 @@ import { ResourceSet } from '../../../util/vs/base/common/map'; import { isEqual } from '../../../util/vs/base/common/resources'; import { createTimeout } from '../../inlineEdits/common/common'; import { IToolsService } from '../../tools/common/toolsService'; -import { RepositoryProperties } from '../common/chatSessionMetadataStore'; +import { RepositoryProperties, IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; import { @@ -67,6 +67,7 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol protected readonly workspaceService: IWorkspaceService, protected readonly logService: ILogService, protected readonly toolsService: IToolsService, + protected readonly metadataStore: IChatSessionMetadataStore ) { super(); @@ -211,7 +212,8 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol // If we're in a single folder workspace, possible the user has opened the worktree folder directly. if (sessionId && folderUri) { - worktreeProperties = await this.worktreeService.getWorktreeProperties(folderUri); + const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri); + worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined; worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined; repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri; } @@ -239,7 +241,8 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol // If we're in a single folder workspace, possible the user has opened the worktree folder directly. if (sessionId && folderUri) { - worktreeProperties = await this.worktreeService.getWorktreeProperties(folderUri); + const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri); + worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined; worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined; repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri; } @@ -855,9 +858,10 @@ export class CopilotCLIFolderRepositoryManager extends FolderRepositoryManager { @IWorkspaceService workspaceService: IWorkspaceService, @ILogService logService: ILogService, @IToolsService toolsService: IToolsService, - @IFileSystemService private readonly fileSystem: IFileSystemService + @IFileSystemService private readonly fileSystem: IFileSystemService, + @IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore ) { - super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService); + super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore); } /** @@ -898,9 +902,10 @@ export class ClaudeFolderRepositoryManager extends FolderRepositoryManager { @ILogService logService: ILogService, @IToolsService toolsService: IToolsService, @IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService, - @IFileSystemService private readonly fileSystem: IFileSystemService + @IFileSystemService private readonly fileSystem: IFileSystemService, + @IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore ) { - super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService); + super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore); } /** diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts index 4df78020aee..bd928872a6f 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts @@ -372,95 +372,8 @@ describe('ChatSessionMetadataStore', () => { store.dispose(); }); - it('should return worktree properties when looked up by folder Uri', async () => { - const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/my-wt').fsPath }); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-wt': { worktreeProperties: props }, - })); - - const store = await createStore(); - const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/my-wt')); - expect(wt).toBeDefined(); - expect(wt!.branchName).toBe(props.branchName); - store.dispose(); - }); - - it('should return undefined when folder Uri does not match any worktree', async () => { - const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt-a').fsPath }); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-wt': { worktreeProperties: props }, - })); - - const store = await createStore(); - const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/wt-b')); - expect(wt).toBeUndefined(); - store.dispose(); - }); - - it('should skip entries without worktreePath when looking up by folder Uri', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-folder': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 } }, - 'session-wt': { worktreeProperties: makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt').fsPath }) }, - })); - - const store = await createStore(); - const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/wt')); - expect(wt).toBeDefined(); - store.dispose(); - }); }); - // ────────────────────────────────────────────────────────────────────────── - // getSessionIdForWorktree - // ────────────────────────────────────────────────────────────────────────── - describe('getSessionIdForWorktree', () => { - it('should return session id when worktree folder matches', async () => { - const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/my-wt').fsPath }); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-abc': { worktreeProperties: props }, - })); - - const store = await createStore(); - const sessionId = await store.getSessionIdForWorktree(Uri.file('/repo/.worktrees/my-wt')); - expect(sessionId).toBe('session-abc'); - store.dispose(); - }); - - it('should return undefined when no worktree matches the folder', async () => { - const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt-a').fsPath }); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-abc': { worktreeProperties: props }, - })); - - const store = await createStore(); - const sessionId = await store.getSessionIdForWorktree(Uri.file('/some/other/path')); - expect(sessionId).toBeUndefined(); - store.dispose(); - }); - - it('should return undefined when cache has no worktree entries', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-folder': { workspaceFolder: { folderPath: Uri.file('/a').fsPath, timestamp: 1 } }, - })); - - const store = await createStore(); - const sessionId = await store.getSessionIdForWorktree(Uri.file('/a')); - expect(sessionId).toBeUndefined(); - store.dispose(); - }); - - it('should find correct session among multiple worktree entries', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'session-1': { worktreeProperties: makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt1').fsPath }) }, - 'session-2': { worktreeProperties: makeWorktreeV2Props({ worktreePath: Uri.file('/repo/.worktrees/wt2').fsPath }) }, - })); - - const store = await createStore(); - const sessionId = await store.getSessionIdForWorktree(Uri.file('/repo/.worktrees/wt2')); - expect(sessionId).toBe('session-2'); - store.dispose(); - }); - }); // ────────────────────────────────────────────────────────────────────────── // getSessionWorkspaceFolder @@ -1548,83 +1461,6 @@ describe('ChatSessionMetadataStore', () => { }); }); - describe('JSONL worktree index', () => { - const jsonlUri = () => Uri.file(jsonlPathHolder.get()); - - async function readJsonl(): Promise>> { - try { - const bytes = await mockFs.readFile(jsonlUri()); - const raw = new TextDecoder().decode(bytes); - return raw.split('\n').filter(Boolean).map(l => JSON.parse(l)); - } catch { - return []; - } - } - - it('appends one line per worktree session and reads it back via getSessionIdForWorktree', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - const store = await createStore(); - - const folder = Uri.file('/repo/.worktrees/wt-A'); - await store.storeWorktreeInfo('wt-session-A', makeWorktreeV1Props({ worktreePath: folder.fsPath })); - - const lines = await readJsonl(); - expect(lines).toHaveLength(1); - expect(lines[0]).toMatchObject({ id: 'wt-session-A', path: folder.fsPath }); - - // Lookup by folder works via the in-memory map. - expect(await store.getSessionIdForWorktree(folder)).toBe('wt-session-A'); - store.dispose(); - }); - - it('falls back to JSONL on disk for getSessionIdForWorktree when in-memory cache is cold', async () => { - // Pre-seed JSONL in mock fs before the store starts — simulates an entry written by another process. - const folder = Uri.file('/repo/.worktrees/wt-cold'); - mockFs.mockFile( - jsonlUri(), - JSON.stringify({ id: 'wt-session-cold', path: folder.fsPath, created: 100 }) + '\n', - ); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - - const store = await createStore(); - expect(await store.getSessionIdForWorktree(folder)).toBe('wt-session-cold'); - store.dispose(); - }); - - it('compacts duplicate JSONL lines for the same id on next rewrite', async () => { - // Two entries for the same id — last write wins, file should be rewritten. - const folder = Uri.file('/repo/.worktrees/dup'); - mockFs.mockFile( - jsonlUri(), - JSON.stringify({ id: 'dup-id', path: '/old/path', created: 1 }) + '\n' + - JSON.stringify({ id: 'dup-id', path: folder.fsPath, created: 2 }) + '\n', - ); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - - const store = await createStore(); - // Initialization should have detected the duplicate and rewritten the file. - const lines = await readJsonl(); - expect(lines).toHaveLength(1); - expect(lines[0]).toMatchObject({ id: 'dup-id', path: folder.fsPath }); - expect(await store.getSessionIdForWorktree(folder)).toBe('dup-id'); - store.dispose(); - }); - - it('removes the JSONL entry when a session is deleted', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - const store = await createStore(); - const folder = Uri.file('/repo/.worktrees/to-delete'); - await store.storeWorktreeInfo('to-delete', makeWorktreeV1Props({ worktreePath: folder.fsPath })); - expect(await readJsonl()).toHaveLength(1); - - await store.deleteSessionMetadata('to-delete'); - - expect(await readJsonl()).toHaveLength(0); - expect(await store.getSessionIdForWorktree(folder)).toBeUndefined(); - store.dispose(); - }); - }); - describe('top-N trim (MAX_BULK_STORAGE_ENTRIES = 1000)', () => { it('writes at most 1000 entries to the bulk file but keeps everything in memory', async () => { // Pre-seed a bulk file with 1100 entries with varying `modified` timestamps. @@ -1711,69 +1547,4 @@ describe('ChatSessionMetadataStore', () => { store.dispose(); }); }); - - describe('session-state directory scan', () => { - const sessionStateDir = Uri.file('/mock/session-state'); - - it('discovers worktree sessions from per-session files not in cache or JSONL', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - // Simulate a per-session file on disk that is NOT in the bulk cache. - const folder = Uri.file('/repo/.worktrees/discovered'); - await mockFs.createDirectory(Uri.joinPath(sessionStateDir, 'orphan-session')); - mockFs.mockFile( - sessionMetadataFileUri('orphan-session'), - JSON.stringify({ worktreeProperties: makeWorktreeV1Props({ worktreePath: folder.fsPath }) }), - ); - // readDirectory returns the session dir entries. - mockFs.mockDirectory(sessionStateDir, [['orphan-session', 2 /* Directory */]]); - - const store = await createStore(); - - expect(await store.getSessionIdForWorktree(folder)).toBe('orphan-session'); - store.dispose(); - }); - - it('skips session IDs already known from the bulk cache', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({ - 'known-session': { workspaceFolder: { folderPath: Uri.file('/known').fsPath, timestamp: 1 } }, - })); - mockFs.mockDirectory(sessionStateDir, [['known-session', 2]]); - - const readSpy = vi.spyOn(mockFs, 'readFile'); - const store = await createStore(); - - // Per-session file for known-session should NOT have been read by the scan. - const scanReads = readSpy.mock.calls.filter( - c => c[0].toString().includes('/mock/session-state/known-session/vscode.metadata.json'), - ); - expect(scanReads).toHaveLength(0); - store.dispose(); - }); - - it('sets memento flag so the scan does not re-run on next startup', async () => { - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - mockFs.mockDirectory(sessionStateDir, []); - - await createStore(); - expect(extensionContext.globalState.get('github.copilot.cli.events.jsonl.scaned')).toBe(true); - }); - - it('skips scan when memento flag is already set', async () => { - extensionContext.globalState.seed('github.copilot.cli.events.jsonl.scaned', true); - mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({})); - // Even with a discoverable session, scan should be skipped. - await mockFs.createDirectory(Uri.joinPath(sessionStateDir, 'should-skip')); - mockFs.mockFile( - sessionMetadataFileUri('should-skip'), - JSON.stringify({ worktreeProperties: makeWorktreeV1Props() }), - ); - mockFs.mockDirectory(sessionStateDir, [['should-skip', 2]]); - - const store = await createStore(); - - expect(await store.getSessionIdForWorktree(Uri.file(makeWorktreeV1Props().worktreePath))).toBeUndefined(); - store.dispose(); - }); - }); - }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index fc58d58e11e..24f16b70554 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -414,7 +414,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { workspaceService, logService, tools, - fileSystem + fileSystem, + new MockChatSessionMetadataStore() ); instantiationService = accessor.get(IInstantiationService); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts index 1318a17f678..d691c78203a 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/folderRepositoryManager.spec.ts @@ -22,6 +22,7 @@ import { ChatSessionWorktreeFile, ChatSessionWorktreeProperties, IChatSessionWor import { IFolderRepositoryManager } from '../../common/folderRepositoryManager'; import { ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService'; import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl'; +import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore'; import type { IClaudeSessionStateService } from '../../claude/common/claudeSessionStateService'; import type { ClaudeFolderInfo } from '../../claude/common/claudeFolderInfo'; @@ -317,7 +318,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService, logService, toolsService, - fileSystem + fileSystem, + new MockChatSessionMetadataStore() ); }); @@ -551,7 +553,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + new MockChatSessionMetadataStore() ); manager.setNewSessionFolder(sessionId, folderUri); @@ -713,7 +716,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + new MockChatSessionMetadataStore() ); const token = disposables.add(new CancellationTokenSource()).token; const stream = new MockChatResponseStream(); @@ -733,8 +737,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { describe('worktree folder opened as workspace folder', () => { const mockToolInvocationToken = {} as vscode.ChatParticipantToolToken; - const worktreeFolderPath = '/repo-worktree'; - const originalRepoPath = '/original-repo'; + const worktreeFolderPath = vscode.Uri.file('/repo-worktree').fsPath; + const originalRepoPath = vscode.Uri.file('/original-repo').fsPath; const defaultWorktreeProps: ChatSessionWorktreeProperties = { autoCommit: true, baseCommit: 'abc123', @@ -745,6 +749,15 @@ describe('CopilotCLIFolderRepositoryManager', () => { }; describe('initializeFolderRepository', () => { + function createMetadataStoreWithWorktree(): MockChatSessionMetadataStore { + const store = new MockChatSessionMetadataStore(); + // Register a session whose worktree path matches worktreeFolderPath so that + // getWorktreeSessions(folderUri) returns a session ID that the worktreeService + // can resolve via getWorktreeProperties. + void store.storeWorktreeInfo(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps); + return store; + } + it('skips worktree creation when single workspace folder is already a tracked worktree', async () => { workspaceService = new MockWorkspaceService([URI.file(worktreeFolderPath)]); gitService.setTestActiveRepository({ @@ -755,7 +768,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager = new CopilotCLIFolderRepositoryManager( worktreeService, workspaceFolderService, sessionService, gitService, workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + createMetadataStoreWithWorktree() ); const sessionId = 'untitled:wt-test-1'; @@ -773,6 +787,12 @@ describe('CopilotCLIFolderRepositoryManager', () => { it('skips worktree creation when explicitly selected folder is a tracked worktree', async () => { worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps); + manager = new CopilotCLIFolderRepositoryManager( + worktreeService, workspaceFolderService, sessionService, + gitService, workspaceService, logService, toolsService, + new MockFileSystemService(), + createMetadataStoreWithWorktree() + ); const sessionId = 'untitled:wt-test-2'; const token = disposables.add(new CancellationTokenSource()).token; @@ -801,7 +821,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager = new CopilotCLIFolderRepositoryManager( worktreeService, workspaceFolderService, sessionService, gitService, workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + createMetadataStoreWithWorktree() ); const sessionId = 'untitled:wt-test-3'; @@ -822,6 +843,12 @@ describe('CopilotCLIFolderRepositoryManager', () => { remotes: [] as string[], changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [{ path: 'other.ts' }], mergeChanges: [], untrackedChanges: [] } } as unknown as RepoContext); + manager = new CopilotCLIFolderRepositoryManager( + worktreeService, workspaceFolderService, sessionService, + gitService, workspaceService, logService, toolsService, + new MockFileSystemService(), + createMetadataStoreWithWorktree() + ); const sessionId = 'untitled:wt-test-4'; const token = disposables.add(new CancellationTokenSource()).token; @@ -844,6 +871,12 @@ describe('CopilotCLIFolderRepositoryManager', () => { kind: 'repository', remotes: [] as string[], } as RepoContext); + manager = new CopilotCLIFolderRepositoryManager( + worktreeService, workspaceFolderService, sessionService, + gitService, workspaceService, logService, toolsService, + new MockFileSystemService(), + createMetadataStoreWithWorktree() + ); const sessionId = 'untitled:wt-test-5'; const token = disposables.add(new CancellationTokenSource()).token; @@ -869,7 +902,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager = new CopilotCLIFolderRepositoryManager( worktreeService, workspaceFolderService, sessionService, gitService, workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + createMetadataStoreWithWorktree() ); const sessionId = 'untitled:wt-test-6'; @@ -895,7 +929,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { manager = new CopilotCLIFolderRepositoryManager( worktreeService, workspaceFolderService, sessionService, gitService, workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + new MockChatSessionMetadataStore() ); (worktreeService.createWorktree as unknown as ReturnType).mockResolvedValue({ @@ -1030,7 +1065,8 @@ describe('CopilotCLIFolderRepositoryManager', () => { workspaceService, logService, toolsService, - new MockFileSystemService() + new MockFileSystemService(), + new MockChatSessionMetadataStore() ); const sessionId = 'untitled:empty-test'; @@ -1099,7 +1135,8 @@ describe('ClaudeFolderRepositoryManager', () => { logService, toolsService, sessionStateService, - fileSystem + fileSystem, + new MockChatSessionMetadataStore() ); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts deleted file mode 100644 index c397ac52a18..00000000000 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/worktreeSessionIndex.spec.ts +++ /dev/null @@ -1,190 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { Uri } from 'vscode'; -import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; -import { ILogService } from '../../../../platform/log/common/logService'; -import { mock } from '../../../../util/common/test/simpleMock'; -import { WorktreeSessionIndex } from '../worktreeSessionIndex'; - -class MockLogService extends mock() { - override trace = vi.fn(); - override info = vi.fn(); - override warn = vi.fn(); - override error = vi.fn(); - override debug = vi.fn(); -} - -const JSONL_PATH = '/mock/copilot-home/worktree.jsonl'; -const JSONL_URI = Uri.file(JSONL_PATH); - -describe('WorktreeSessionIndex', () => { - let mockFs: MockFileSystemService; - let logService: MockLogService; - - function createIndex(): WorktreeSessionIndex { - return new WorktreeSessionIndex(mockFs, logService, JSONL_PATH); - } - - afterEach(() => { - vi.restoreAllMocks(); - }); - - // In-memory tests don't need mockFs/logService, but the constructor requires them. - describe('in-memory operations', () => { - it('adds and retrieves an entry by session id', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - - expect(index.getSessionEntry('s1')).toMatchObject({ id: 's1', path: '/a' }); - expect(index.has('s1')).toBe(true); - expect(index.has('s2')).toBe(false); - }); - - it('looks up session id by folder Uri', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - - expect(index.getSessionIdForFolder(Uri.file('/a'))).toBe('s1'); - expect(index.getSessionIdForFolder(Uri.file('/b'))).toBeUndefined(); - }); - - it('deletes an entry and cleans up the folder mapping', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.deleteEntry('s1'); - - expect(index.has('s1')).toBe(false); - expect(index.getSessionIdForFolder(Uri.file('/a'))).toBeUndefined(); - }); - - it('clear() removes everything', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.addEntry({ id: 's2', path: '/b', created: 2 }); - index.clear(); - - expect(index.has('s1')).toBe(false); - expect(index.getEntries()).toHaveLength(0); - }); - - it('getEntries() returns all entries', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.addEntry({ id: 's2', path: '/b', created: 2 }); - - const entries = index.getEntries(); - expect(entries).toHaveLength(2); - expect(entries.map(e => e.id).sort()).toEqual(['s1', 's2']); - }); - - it('updating an entry with a new path removes the old path mapping', () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/old', created: 1 }); - expect(index.getSessionIdForFolder(Uri.file('/old'))).toBe('s1'); - - index.addEntry({ id: 's1', path: '/new', created: 1 }); - expect(index.getSessionIdForFolder(Uri.file('/new'))).toBe('s1'); - expect(index.getSessionIdForFolder(Uri.file('/old'))).toBeUndefined(); - }); - }); - - describe('JSONL persistence', () => { - it('loadFromDisk populates the index from a JSONL file', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - mockFs.mockFile(JSONL_URI, - JSON.stringify({ id: 's1', path: '/a', created: 1 }) + '\n' + - JSON.stringify({ id: 's2', path: '/b', created: 2 }) + '\n', - ); - const index = createIndex(); - await index.loadFromDisk(); - - expect(index.has('s1')).toBe(true); - expect(index.has('s2')).toBe(true); - expect(index.size).toBe(2); - }); - - it('loadFromDisk returns rewriteNeeded for duplicates', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - mockFs.mockFile(JSONL_URI, - JSON.stringify({ id: 's1', path: '/a', created: 1 }) + '\n' + - JSON.stringify({ id: 's1', path: '/b', created: 2 }) + '\n', - ); - const index = createIndex(); - const { rewriteNeeded } = await index.loadFromDisk(); - - expect(rewriteNeeded).toBe(true); - expect(index.size).toBe(1); - }); - - it('writeToDisk writes all entries to the JSONL file', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.addEntry({ id: 's2', path: '/b', created: 2 }); - await index.writeToDisk(); - - const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI)); - const lines = raw.split('\n').filter(Boolean); - expect(lines).toHaveLength(2); - }); - - it('appendBatchToDisk adds a single entry', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - await index.appendBatchToDisk([{ id: 's1', path: '/a', created: 1 }]); - - expect(index.has('s1')).toBe(true); - const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI)); - expect(raw.split('\n').filter(Boolean)).toHaveLength(1); - }); - - it('appendBatchToDisk adds multiple entries in one write', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - await index.appendBatchToDisk([ - { id: 's1', path: '/a', created: 1 }, - { id: 's2', path: '/b', created: 2 }, - ]); - - expect(index.has('s1')).toBe(true); - expect(index.has('s2')).toBe(true); - const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI)); - expect(raw.split('\n').filter(Boolean)).toHaveLength(2); - }); - - it('removeAndWriteToDisk removes the entry and rewrites', async () => { - mockFs = new MockFileSystemService(); - logService = new MockLogService(); - const index = createIndex(); - index.addEntry({ id: 's1', path: '/a', created: 1 }); - index.addEntry({ id: 's2', path: '/b', created: 2 }); - await index.removeAndWriteToDisk('s1'); - - expect(index.has('s1')).toBe(false); - const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI)); - expect(raw.split('\n').filter(Boolean)).toHaveLength(1); - expect(raw).toContain('s2'); - }); - }); -}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts deleted file mode 100644 index ca8586c639f..00000000000 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/worktreeSessionIndex.ts +++ /dev/null @@ -1,221 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { Uri } from 'vscode'; -import { createDirectoryIfNotExists, IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; -import { ILogService } from '../../../platform/log/common/logService'; -import { Sequencer } from '../../../util/vs/base/common/async'; -import { ResourceMap } from '../../../util/vs/base/common/map'; -import { dirname } from '../../../util/vs/base/common/resources'; -import { WorktreeSessionEntry } from '../common/chatSessionMetadataStore'; - -/** - * In-memory index that maps session ids to {@link WorktreeSessionEntry} and - * worktree folder URIs to session ids, with JSONL file persistence. - * - * When multiple sessions share the same folder, the first-registered session - * keeps the folder → session-id mapping. - * - * All file writes are serialized through an internal {@link Sequencer} so - * concurrent appends and rewrites cannot race. - */ -export class WorktreeSessionIndex { - /** Session id → entry. */ - private readonly _byId = new Map(); - /** Worktree folder URI → session id. Uses URI-aware comparison so path casing is handled correctly. */ - private readonly _byFolder = new ResourceMap(); - /** Serializes all JSONL file writes to prevent read-modify-write races. */ - private readonly _writeSequencer = new Sequencer(); - /** Timestamp of the last {@link loadFromDisk} call; used by {@link reloadIfStale}. */ - private _lastLoadAt = 0; - - constructor( - private readonly _fileSystemService: IFileSystemService, - private readonly _logService: ILogService, - private readonly _jsonlPath: string, - ) { } - - getSessionEntry(sessionId: string): WorktreeSessionEntry | undefined { - return this._byId.get(sessionId); - } - - getSessionIdForFolder(folder: Uri): string | undefined { - return this._byFolder.get(folder); - } - - has(sessionId: string): boolean { - return this._byId.has(sessionId); - } - - get size(): number { - return this._byId.size; - } - - getAllSessionIds(): string[] { - return Array.from(this._byId.keys()); - } - - /** - * Adds or updates an entry. When the same folder path is already mapped to - * a different session, the existing mapping is preserved. - */ - addEntry(entry: WorktreeSessionEntry): void { - const folderUri = Uri.file(entry.path); - - // If this session already has an entry with a different path, clean up - // the old folder → session-id mapping before recording the new one. - const previousEntry = this._byId.get(entry.id); - if (previousEntry && previousEntry.path !== entry.path) { - const prevUri = Uri.file(previousEntry.path); - if (this._byFolder.get(prevUri) === entry.id) { - this._byFolder.delete(prevUri); - } - } - - this._byId.set(entry.id, entry); - - const existingIdForFolder = this._byFolder.get(folderUri); - if (!existingIdForFolder) { - this._byFolder.set(folderUri, entry.id); - return; - } - if (existingIdForFolder === entry.id) { - return; - } - const existingEntry = this._byId.get(existingIdForFolder); - if (existingEntry) { - return; - } - this._byFolder.set(folderUri, entry.id); - } - - deleteEntry(sessionId: string): void { - const entry = this._byId.get(sessionId); - if (!entry) { - return; - } - this._byId.delete(sessionId); - const folderUri = Uri.file(entry.path); - if (this._byFolder.get(folderUri) === sessionId) { - this._byFolder.delete(folderUri); - for (const candidate of this._byId.values()) { - if (candidate.path === entry.path) { - this._byFolder.set(folderUri, candidate.id); - break; - } - } - } - } - - clear(): void { - this._byId.clear(); - this._byFolder.clear(); - } - - getEntries(): WorktreeSessionEntry[] { - return Array.from(this._byId.values()); - } - - /** - * Loads the JSONL worktree index from disk into the in-memory maps. - * Returns `rewriteNeeded` if the file contained malformed lines or - * duplicates that should be compacted via {@link writeToDisk}. - */ - async loadFromDisk(): Promise<{ rewriteNeeded: boolean }> { - let rewriteNeeded = false; - let raw: string; - try { - const bytes = await this._fileSystemService.readFile(Uri.file(this._jsonlPath)); - raw = new TextDecoder().decode(bytes); - } catch { - this._lastLoadAt = Date.now(); - return { rewriteNeeded: false }; - } - this.clear(); - for (const line of raw.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - try { - const entry = JSON.parse(trimmed) as WorktreeSessionEntry; - if (!entry?.id || !entry.path) { - rewriteNeeded = true; - continue; - } - if (this._byId.has(entry.id)) { - rewriteNeeded = true; - } - this.addEntry(entry); - } catch { - rewriteNeeded = true; - } - } - this._lastLoadAt = Date.now(); - return { rewriteNeeded }; - } - - /** Reloads from disk only if more than 1 second has passed since the last load. */ - async reloadIfStale(): Promise { - if (Date.now() - this._lastLoadAt < 1000) { - return; - } - await this.loadFromDisk(); - } - - /** Writes the entire in-memory index to the JSONL file, replacing its contents. */ - async writeToDisk(): Promise { - return this._writeSequencer.queue(async () => { - try { - const jsonlUri = Uri.file(this._jsonlPath); - await createDirectoryIfNotExists(this._fileSystemService, dirname(jsonlUri)); - const lines = this._byId.size > 0 - ? Array.from(this._byId.values()).map(e => JSON.stringify(e)).join('\n') + '\n' - : ''; - await this._fileSystemService.writeFile(jsonlUri, new TextEncoder().encode(lines)); - } catch (err) { - this._logService.error('[WorktreeSessionIndex] Failed to write JSONL: ', err); - } - }); - } - - /** Appends entries to the JSONL file and adds them to the in-memory index. */ - async appendBatchToDisk(entries: WorktreeSessionEntry[]): Promise { - if (entries.length === 0) { - return; - } - return this._writeSequencer.queue(async () => { - try { - const jsonlUri = Uri.file(this._jsonlPath); - await createDirectoryIfNotExists(this._fileSystemService, dirname(jsonlUri)); - let existing = ''; - try { - existing = new TextDecoder().decode(await this._fileSystemService.readFile(jsonlUri)); - } catch { - // File doesn't exist yet. - } - const suffix = entries.map(e => JSON.stringify(e)).join('\n') + '\n'; - await this._fileSystemService.writeFile( - jsonlUri, - new TextEncoder().encode(existing + suffix), - ); - for (const entry of entries) { - this.addEntry(entry); - } - } catch (err) { - this._logService.error('[WorktreeSessionIndex] Failed to bulk-append entries: ', err); - } - }); - } - - /** Removes an entry from the in-memory index and rewrites the JSONL file. */ - async removeAndWriteToDisk(sessionId: string): Promise { - if (!this._byId.has(sessionId)) { - return; - } - this.deleteEntry(sessionId); - await this.writeToDisk(); - } -} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index f28419d86b9..b8686c50146 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -546,19 +546,20 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private readonly _resolvedResources = new ResourceSet(); observeSession(resource: URI): IObservable { + // Trigger resolve if not yet resolved for this resource (or if + // the guard was cleared after a provider refresh). This is + // separated from the observable cache so that re-calls after a + // refresh re-trigger the resolve RPC even though the observable + // already exists. + if (!this._resolvedResources.has(resource)) { + this._resolvedResources.add(resource); + const sessionType = getChatSessionType(resource); + this.chatSessionsService.resolveChatSessionItem(sessionType, resource, CancellationToken.None) + .catch(error => this.logger.logIfTrace(`observeSession: resolve failed for ${resource.toString()}: ${error instanceof Error ? error.message : String(error)}`)); + } + let observable = this._sessionObservables.get(resource); if (!observable) { - // Lazily trigger a resolve for this resource so consumers reading - // lazy properties (e.g. `changes`) get fresh data without needing - // to wait for a tree row to scroll into view. The chat sessions - // service deduplicates in-flight resolves by resource. - if (!this._resolvedResources.has(resource)) { - this._resolvedResources.add(resource); - const sessionType = getChatSessionType(resource); - this.chatSessionsService.resolveChatSessionItem(sessionType, resource, CancellationToken.None) - .catch(error => this.logger.logIfTrace(`observeSession: resolve failed for ${resource.toString()}: ${error instanceof Error ? error.message : String(error)}`)); - } - this._changedSignal ??= observableSignalFromEvent('agentSessionsChanged', this.onDidChangeSessions); const signal = this._changedSignal; observable = derived(reader => { @@ -610,6 +611,22 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode private async doResolveProvider(provider: string, options: { refreshProvider: boolean }, token: CancellationToken): Promise { if (options.refreshProvider) { await this.chatSessionsService.refreshChatSessionItems([provider], token); + + // Clear the resolve-once guard for sessions belonging to this + // provider and re-trigger resolve for any that were previously + // observed. This is necessary because the refresh returns items + // with lazy properties (e.g. changes: undefined) that need a + // fresh resolve RPC. Re-calling observeSession() for resources + // already in _sessionObservables is cheap (the observable is + // cached) and only fires the RPC side-effect. + for (const resource of [...this._resolvedResources]) { + if (getChatSessionType(resource) === provider) { + this._resolvedResources.delete(resource); + if (this._sessionObservables.has(resource)) { + this.observeSession(resource); + } + } + } } const mapSessionContributionToType = new Map();