mirror of
https://github.com/microsoft/vscode.git
synced 2026-05-24 09:21:35 +01:00
Refactor chat session worktree handling and remove unused components (#312109)
* Refactor chat session worktree handling and remove unused components Co-authored-by: Copilot <copilot@github.com> * Updates * Updates * Updates * Updats Co-authored-by: Copilot <copilot@github.com> * Updates * Fixes * Refactor session working directory management to use chat session metadata store Co-authored-by: Copilot <copilot@github.com> * Updates Co-authored-by: Copilot <copilot@github.com> --------- Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -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<void>;
|
||||
storeRepositoryProperties(sessionId: string, properties: RepositoryProperties): Promise<void>;
|
||||
getRepositoryProperties(sessionId: string): Promise<RepositoryProperties | undefined>;
|
||||
getSessionIdForWorktree(folder: vscode.Uri): Promise<string | undefined>;
|
||||
getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined>;
|
||||
getWorktreeProperties(folder: Uri): Promise<ChatSessionWorktreeProperties | undefined>;
|
||||
getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined>;
|
||||
getSessionWorkspaceFolderEntry(sessionId: string): Promise<WorkspaceFolderEntry | undefined>;
|
||||
getAdditionalWorkspaces(sessionId: string): Promise<IWorkspaceInfo[]>;
|
||||
@@ -147,4 +144,13 @@ export interface IChatSessionMetadataStore {
|
||||
* on demand. Concurrent calls collapse: at most one in-flight + one pending.
|
||||
*/
|
||||
refresh(): Promise<void>;
|
||||
/**
|
||||
* 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[];
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ export interface IChatSessionWorktreeService {
|
||||
createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise<ChatSessionWorktreeProperties | undefined>;
|
||||
|
||||
getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined>;
|
||||
getWorktreeProperties(folder: vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined>;
|
||||
setWorktreeProperties(sessionId: string, properties: string | ChatSessionWorktreeProperties): Promise<void>;
|
||||
|
||||
getWorktreeRepository(sessionId: string): Promise<RepoContext | undefined>;
|
||||
@@ -71,8 +70,6 @@ export interface IChatSessionWorktreeService {
|
||||
|
||||
applyWorktreeChanges(sessionId: string): Promise<void>;
|
||||
|
||||
getSessionIdForWorktree(folder: vscode.Uri): Promise<string | undefined>;
|
||||
|
||||
getWorktreeChanges(sessionId: string): Promise<readonly vscode.ChatSessionChangedFile[] | undefined>;
|
||||
|
||||
hasCachedChanges(sessionId: string): Promise<boolean>;
|
||||
|
||||
+29
-5
@@ -54,11 +54,8 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getWorktreeProperties(sessionIdOrFolder: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> {
|
||||
if (typeof sessionIdOrFolder === 'string') {
|
||||
return this._worktreeProperties.get(sessionIdOrFolder);
|
||||
}
|
||||
return undefined;
|
||||
async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {
|
||||
return this._worktreeProperties.get(sessionId);
|
||||
}
|
||||
|
||||
async getSessionWorkspaceFolder(_sessionId: string): Promise<vscode.Uri | undefined> {
|
||||
@@ -155,4 +152,31 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
|
||||
getSessionParentId(_sessionId: string): Promise<string | undefined> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
+84
-185
@@ -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<string, ChatSessionMetadataFile> = {};
|
||||
|
||||
/** 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<string, { path: string; kind: 'worktree' | 'folder' }>();
|
||||
/** Folder path → set of session IDs (worktree path or workspace folder path). */
|
||||
private readonly _folderToSessions = new Map<string, Set<string>>();
|
||||
|
||||
/** 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<void> {
|
||||
// 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<string, ChatSessionMetadataFile>));
|
||||
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<ChatSessionWorktreeProperties | undefined>;
|
||||
getWorktreeProperties(folder: Uri): Promise<ChatSessionWorktreeProperties | undefined>;
|
||||
async getWorktreeProperties(sessionId: string | Uri): Promise<ChatSessionWorktreeProperties | undefined> {
|
||||
async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {
|
||||
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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<vscode.Uri | undefined> {
|
||||
@@ -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<unknown>[] = [];
|
||||
|
||||
// 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<void> {
|
||||
// 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<void> {
|
||||
if (this.extensionContext.globalState.get<boolean>(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)`);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-25
@@ -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<void> {
|
||||
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 {
|
||||
|
||||
+6
-34
@@ -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<ChatSessionWorktreeProperties | undefined>;
|
||||
getWorktreeProperties(folder: vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined>;
|
||||
async getWorktreeProperties(sessionIdOrFolder: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> {
|
||||
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<ChatSessionWorktreeProperties | undefined> {
|
||||
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<void> {
|
||||
@@ -397,18 +381,6 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
|
||||
}
|
||||
}
|
||||
|
||||
async getSessionIdForWorktree(folder: vscode.Uri): Promise<string | undefined> {
|
||||
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<void> {
|
||||
const worktreeProperties = await this.getWorktreeProperties(sessionId);
|
||||
if (!worktreeProperties) {
|
||||
|
||||
+13
-17
@@ -131,7 +131,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
|
||||
|
||||
private readonly controller: vscode.ChatSessionItemController;
|
||||
private readonly newSessions = new ResourceMap<vscode.ChatSessionItem>();
|
||||
|
||||
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<void> {
|
||||
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<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
|
||||
private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
|
||||
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 };
|
||||
|
||||
+11
-14
@@ -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<vscode.ChatSessionItem | undefined>;
|
||||
|
||||
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<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
|
||||
private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
|
||||
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();
|
||||
|
||||
+12
-7
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
-229
@@ -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<Array<Record<string, unknown>>> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
+2
-1
@@ -414,7 +414,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
|
||||
workspaceService,
|
||||
logService,
|
||||
tools,
|
||||
fileSystem
|
||||
fileSystem,
|
||||
new MockChatSessionMetadataStore()
|
||||
);
|
||||
|
||||
instantiationService = accessor.get(IInstantiationService);
|
||||
|
||||
+48
-11
@@ -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<typeof vi.fn>).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()
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
-190
@@ -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<ILogService>() {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, WorktreeSessionEntry>();
|
||||
/** Worktree folder URI → session id. Uses URI-aware comparison so path casing is handled correctly. */
|
||||
private readonly _byFolder = new ResourceMap<string>();
|
||||
/** 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
if (!this._byId.has(sessionId)) {
|
||||
return;
|
||||
}
|
||||
this.deleteEntry(sessionId);
|
||||
await this.writeToDisk();
|
||||
}
|
||||
}
|
||||
@@ -546,19 +546,20 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
|
||||
private readonly _resolvedResources = new ResourceSet();
|
||||
|
||||
observeSession(resource: URI): IObservable<IAgentSession | undefined> {
|
||||
// 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<void> {
|
||||
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<string, ResolvedChatSessionsExtensionPoint>();
|
||||
|
||||
Reference in New Issue
Block a user