豆豆友情提示:这是一个非官方 GitHub 代理镜像,主要用于网络测试或访问加速。请勿在此进行登录、注册或处理任何敏感信息。进行这些操作请务必访问官方网站 github.com。 Raw 内容也通过此代理提供。
Skip to content

Commit 4cdf674

Browse files
authored
Add methods to manage additional copilot cli session metadata (#4300)
* Add methods to manage additional copilot cli session metadata * Updates
1 parent 7523b99 commit 4cdf674

File tree

3 files changed

+245
-3
lines changed

3 files changed

+245
-3
lines changed

src/extension/chatSessions/common/chatSessionMetadataStore.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type * as vscode from 'vscode';
77
import type { Uri } from 'vscode';
88
import { createServiceIdentifier } from '../../../util/common/services';
99
import { ChatSessionWorktreeProperties } from './chatSessionWorktreeService';
10+
import type { IWorkspaceInfo } from './workspaceInfo';
1011

1112
export interface WorkspaceFolderEntry {
1213
readonly folderPath: string;
@@ -16,6 +17,10 @@ export interface WorkspaceFolderEntry {
1617
export interface ChatSessionMetadataFile {
1718
worktreeProperties?: ChatSessionWorktreeProperties;
1819
workspaceFolder?: WorkspaceFolderEntry;
20+
additionalWorkspaces?: {
21+
worktreeProperties?: ChatSessionWorktreeProperties;
22+
workspaceFolder?: WorkspaceFolderEntry;
23+
}[];
1924
/**
2025
* Whether the session metadata has been written to the Copilot CLI session state directory.
2126
*/
@@ -34,4 +39,6 @@ export interface IChatSessionMetadataStore {
3439
getWorktreeProperties(folder: Uri): Promise<ChatSessionWorktreeProperties | undefined>;
3540
getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined>;
3641
getUsedWorkspaceFolders(): Promise<WorkspaceFolderEntry[]>;
42+
getAdditionalWorkspaces(sessionId: string): Promise<IWorkspaceInfo[]>;
43+
setAdditionalWorkspaces(sessionId: string, workspaces: IWorkspaceInfo[]): Promise<void>;
3744
}

src/extension/chatSessions/vscode-node/chatSessionMetadataStoreImpl.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ResourceMap } from '../../../util/vs/base/common/map';
1515
import { dirname, isEqual } from '../../../util/vs/base/common/resources';
1616
import { ChatSessionMetadataFile, IChatSessionMetadataStore, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore';
1717
import { ChatSessionWorktreeData, ChatSessionWorktreeProperties } from '../common/chatSessionWorktreeService';
18+
import { IWorkspaceInfo } from '../common/workspaceInfo';
1819
import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers';
1920

2021
const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';
@@ -53,7 +54,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
5354
continue;
5455
}
5556
if (!metadata.writtenToDisc) {
56-
if ((metadata.workspaceFolder || metadata.worktreeProperties)) {
57+
if ((metadata.workspaceFolder || metadata.worktreeProperties || metadata.additionalWorkspaces?.length)) {
5758
this.updateSessionMetadata(sessionId, metadata, false).catch(ex => {
5859
this.logService.error(ex, `[ChatSessionMetadataStore] Failed to write metadata for session ${sessionId} to session state: `);
5960
});
@@ -116,7 +117,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
116117
// These promises can run in background and no need to wait for them.
117118
// Even if user exits early we have all the data in the global storage and we'll restore from that next time.
118119
if (!metadata.writtenToDisc) {
119-
if ((metadata.workspaceFolder || metadata.worktreeProperties)) {
120+
if ((metadata.workspaceFolder || metadata.worktreeProperties || metadata.additionalWorkspaces?.length)) {
120121
this.updateSessionMetadata(sessionId, metadata, false).catch(ex => {
121122
this.logService.error(ex, `[ChatSessionMetadataStore] Failed to write metadata for session ${sessionId} to session state: `);
122123
});
@@ -216,6 +217,33 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
216217
}
217218
return Array.from(entries.entries()).map(([folderUri, timestamp]) => ({ folderPath: folderUri.fsPath, timestamp }));
218219
}
220+
221+
async getAdditionalWorkspaces(sessionId: string): Promise<IWorkspaceInfo[]> {
222+
const metadata = await this.getSessionMetadata(sessionId);
223+
if (!metadata?.additionalWorkspaces?.length) {
224+
return [];
225+
}
226+
return metadata.additionalWorkspaces.map(ws => ({
227+
folder: !ws.worktreeProperties && ws.workspaceFolder?.folderPath ? Uri.file(ws.workspaceFolder.folderPath) : undefined,
228+
repository: ws.worktreeProperties?.repositoryPath ? Uri.file(ws.worktreeProperties.repositoryPath) : undefined,
229+
worktree: ws.worktreeProperties?.worktreePath ? Uri.file(ws.worktreeProperties.worktreePath) : undefined,
230+
worktreeProperties: ws.worktreeProperties,
231+
}));
232+
}
233+
234+
async setAdditionalWorkspaces(sessionId: string, workspaces: IWorkspaceInfo[]): Promise<void> {
235+
await this._intialize.value;
236+
const existing = this._cache[sessionId] ?? {};
237+
const additionalWorkspaces = workspaces.map(ws => ({
238+
worktreeProperties: ws.worktreeProperties,
239+
workspaceFolder: !ws.worktreeProperties && ws.folder ? { folderPath: ws.folder.fsPath, timestamp: Date.now() } : undefined,
240+
}));
241+
const metadata: ChatSessionMetadataFile = { ...existing, additionalWorkspaces };
242+
this._cache[sessionId] = metadata;
243+
await this.updateSessionMetadata(sessionId, metadata);
244+
this.updateGlobalStorage();
245+
}
246+
219247
private async getSessionMetadata(sessionId: string): Promise<ChatSessionMetadataFile | undefined> {
220248
await this._intialize.value;
221249
if (sessionId in this._cache) {

src/extension/chatSessions/vscode-node/test/chatSessionMetadataStoreImpl.spec.ts

Lines changed: 208 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Emitter } from '../../../../util/vs/base/common/event';
1414
import { URI } from '../../../../util/vs/base/common/uri';
1515
import { eventToPromise } from '../../../completions-core/vscode-node/lib/src/prompt/asyncUtils';
1616
import { ChatSessionWorktreeData, ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
17+
import { IWorkspaceInfo } from '../../common/workspaceInfo';
1718
import { getCopilotCLISessionDir } from '../../copilotcli/node/cliHelpers';
1819
import { ChatSessionMetadataStore } from '../chatSessionMetadataStoreImpl';
1920

@@ -339,7 +340,7 @@ describe('ChatSessionMetadataStore', () => {
339340
store.dispose();
340341
});
341342

342-
it('should not retry entries with no workspaceFolder or worktreeProperties', async () => {
343+
it('should not retry entries with no workspaceFolder, worktreeProperties, or additionalWorkspaces', async () => {
343344
const existingData = {
344345
'session-empty': {},
345346
'session-folder': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 } },
@@ -358,6 +359,32 @@ describe('ChatSessionMetadataStore', () => {
358359
expect(sessionEmptyStatCalls).toHaveLength(0);
359360
store.dispose();
360361
});
362+
363+
it('should retry entries that only have additionalWorkspaces (not delete as invalid data)', async () => {
364+
// A session with only additionalWorkspaces and writtenToDisc: false
365+
// must be retried, not deleted from cache — otherwise data is lost after a crash.
366+
const existingData = {
367+
'session-only-additional': {
368+
additionalWorkspaces: [
369+
{ workspaceFolder: { folderPath: Uri.file('/extra/workspace').fsPath, timestamp: 100 } },
370+
],
371+
},
372+
};
373+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify(existingData));
374+
375+
// Pre-create the session directory so the recovery write can succeed
376+
await mockFs.createDirectory(sessionDirectoryUri('session-only-additional'));
377+
const fileCreated = eventToPromise(mockFs.onDidCreateFile.event);
378+
379+
const store = await createStore();
380+
await fileCreated;
381+
382+
const fileUri = sessionMetadataFileUri('session-only-additional');
383+
const rawContent = await mockFs.readFile(fileUri);
384+
const written = JSON.parse(new TextDecoder().decode(rawContent));
385+
expect(written.additionalWorkspaces).toHaveLength(1);
386+
store.dispose();
387+
});
361388
});
362389

363390
// ──────────────────────────────────────────────────────────────────────────
@@ -1409,6 +1436,186 @@ describe('ChatSessionMetadataStore', () => {
14091436
});
14101437
});
14111438

1439+
// ──────────────────────────────────────────────────────────────────────────
1440+
// setAdditionalWorkspaces / getAdditionalWorkspaces
1441+
// ──────────────────────────────────────────────────────────────────────────
1442+
describe('setAdditionalWorkspaces / getAdditionalWorkspaces', () => {
1443+
it('should store and retrieve workspace-folder type additional workspaces', async () => {
1444+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
1445+
const store = await createStore();
1446+
1447+
const workspaces: IWorkspaceInfo[] = [
1448+
{ folder: Uri.file('/extra/a'), repository: undefined, worktree: undefined, worktreeProperties: undefined },
1449+
{ folder: Uri.file('/extra/b'), repository: undefined, worktree: undefined, worktreeProperties: undefined },
1450+
];
1451+
await store.setAdditionalWorkspaces('session-1', workspaces);
1452+
1453+
const result = await store.getAdditionalWorkspaces('session-1');
1454+
expect(result).toHaveLength(2);
1455+
expect(result[0].folder?.fsPath).toBe(Uri.file('/extra/a').fsPath);
1456+
expect(result[1].folder?.fsPath).toBe(Uri.file('/extra/b').fsPath);
1457+
store.dispose();
1458+
});
1459+
1460+
it('should store and retrieve worktree type additional workspaces', async () => {
1461+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
1462+
const store = await createStore();
1463+
const props = makeWorktreeV1Props();
1464+
1465+
const workspaces: IWorkspaceInfo[] = [
1466+
{ folder: undefined, repository: Uri.file('/repo'), worktree: Uri.file('/repo/.worktrees/wt'), worktreeProperties: props },
1467+
];
1468+
await store.setAdditionalWorkspaces('session-wt', workspaces);
1469+
1470+
const result = await store.getAdditionalWorkspaces('session-wt');
1471+
expect(result).toHaveLength(1);
1472+
expect(result[0].worktreeProperties?.branchName).toBe(props.branchName);
1473+
expect(result[0].worktree?.fsPath).toBe(Uri.file('/repo/.worktrees/wt').fsPath);
1474+
// worktreeProperties present → folder should be undefined per getAdditionalWorkspaces logic
1475+
expect(result[0].folder).toBeUndefined();
1476+
store.dispose();
1477+
});
1478+
1479+
it('should return empty array when no additional workspaces are set', async () => {
1480+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
1481+
'session-1': { workspaceFolder: { folderPath: Uri.file('/a').fsPath, timestamp: 1 } },
1482+
}));
1483+
const store = await createStore();
1484+
1485+
const result = await store.getAdditionalWorkspaces('session-1');
1486+
expect(result).toEqual([]);
1487+
store.dispose();
1488+
});
1489+
1490+
it('should return empty array for unknown session', async () => {
1491+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
1492+
const store = await createStore();
1493+
1494+
const result = await store.getAdditionalWorkspaces('nonexistent');
1495+
expect(result).toEqual([]);
1496+
store.dispose();
1497+
});
1498+
1499+
it('should write additionalWorkspaces to per-session file', async () => {
1500+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
1501+
const store = await createStore();
1502+
1503+
await store.setAdditionalWorkspaces('session-1', [
1504+
{ folder: Uri.file('/extra/a'), repository: undefined, worktree: undefined, worktreeProperties: undefined },
1505+
]);
1506+
1507+
const fileUri = sessionMetadataFileUri('session-1');
1508+
const rawContent = await mockFs.readFile(fileUri);
1509+
const written = JSON.parse(new TextDecoder().decode(rawContent));
1510+
expect(written.additionalWorkspaces).toHaveLength(1);
1511+
expect(written.additionalWorkspaces[0].workspaceFolder?.folderPath).toBe(Uri.file('/extra/a').fsPath);
1512+
store.dispose();
1513+
});
1514+
1515+
it('should preserve existing workspaceFolder when setting additionalWorkspaces', async () => {
1516+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
1517+
'session-1': { workspaceFolder: { folderPath: Uri.file('/primary').fsPath, timestamp: 100 } },
1518+
}));
1519+
const store = await createStore();
1520+
1521+
await store.setAdditionalWorkspaces('session-1', [
1522+
{ folder: Uri.file('/extra/a'), repository: undefined, worktree: undefined, worktreeProperties: undefined },
1523+
]);
1524+
1525+
// Primary workspace folder should still be accessible
1526+
const folder = await store.getSessionWorkspaceFolder('session-1');
1527+
expect(folder?.fsPath).toBe(Uri.file('/primary').fsPath);
1528+
1529+
// Additional workspaces should also be present
1530+
const result = await store.getAdditionalWorkspaces('session-1');
1531+
expect(result).toHaveLength(1);
1532+
expect(result[0].folder?.fsPath).toBe(Uri.file('/extra/a').fsPath);
1533+
store.dispose();
1534+
});
1535+
1536+
it('should replace previous additionalWorkspaces on subsequent call', async () => {
1537+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
1538+
const store = await createStore();
1539+
1540+
await store.setAdditionalWorkspaces('session-1', [
1541+
{ folder: Uri.file('/old'), repository: undefined, worktree: undefined, worktreeProperties: undefined },
1542+
]);
1543+
await store.setAdditionalWorkspaces('session-1', [
1544+
{ folder: Uri.file('/new/a'), repository: undefined, worktree: undefined, worktreeProperties: undefined },
1545+
{ folder: Uri.file('/new/b'), repository: undefined, worktree: undefined, worktreeProperties: undefined },
1546+
]);
1547+
1548+
const result = await store.getAdditionalWorkspaces('session-1');
1549+
expect(result).toHaveLength(2);
1550+
expect(result[0].folder?.fsPath).toBe(Uri.file('/new/a').fsPath);
1551+
store.dispose();
1552+
});
1553+
1554+
it('should trigger debounced bulk storage update', async () => {
1555+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
1556+
const store = await createStore();
1557+
1558+
await store.setAdditionalWorkspaces('session-1', [
1559+
{ folder: Uri.file('/extra'), repository: undefined, worktree: undefined, worktreeProperties: undefined },
1560+
]);
1561+
await vi.advanceTimersByTimeAsync(1_100);
1562+
1563+
const rawContent = await mockFs.readFile(BULK_METADATA_FILE);
1564+
const written = JSON.parse(new TextDecoder().decode(rawContent));
1565+
expect(written['session-1']?.additionalWorkspaces).toBeDefined();
1566+
store.dispose();
1567+
});
1568+
1569+
it('should restore additionalWorkspaces from bulk file on startup', async () => {
1570+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
1571+
'session-1': {
1572+
additionalWorkspaces: [
1573+
{ workspaceFolder: { folderPath: Uri.file('/restored/a').fsPath, timestamp: 100 } },
1574+
],
1575+
writtenToDisc: true,
1576+
},
1577+
}));
1578+
const store = await createStore();
1579+
1580+
const result = await store.getAdditionalWorkspaces('session-1');
1581+
expect(result).toHaveLength(1);
1582+
expect(result[0].folder?.fsPath).toBe(Uri.file('/restored/a').fsPath);
1583+
store.dispose();
1584+
});
1585+
1586+
it('should survive crash recovery: entry with only additionalWorkspaces is re-persisted not deleted', async () => {
1587+
// Simulate VS Code crash: bulk file has the entry but writtenToDisc is falsy
1588+
// (updateSessionMetadata never completed before the crash).
1589+
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
1590+
'session-crash': {
1591+
additionalWorkspaces: [
1592+
{ workspaceFolder: { folderPath: Uri.file('/extra/workspace').fsPath, timestamp: 100 } },
1593+
],
1594+
// writtenToDisc intentionally absent (falsy) — simulates crash before write completed
1595+
},
1596+
}));
1597+
1598+
// Pre-create session directory so recovery write can succeed
1599+
await mockFs.createDirectory(sessionDirectoryUri('session-crash'));
1600+
const fileCreated = eventToPromise(mockFs.onDidCreateFile.event);
1601+
1602+
const store = await createStore();
1603+
await fileCreated;
1604+
1605+
// Entry should have been re-persisted to per-session file
1606+
const fileUri = sessionMetadataFileUri('session-crash');
1607+
const rawContent = await mockFs.readFile(fileUri);
1608+
const written = JSON.parse(new TextDecoder().decode(rawContent));
1609+
expect(written.additionalWorkspaces).toHaveLength(1);
1610+
expect(written.additionalWorkspaces[0].workspaceFolder?.folderPath).toBe(Uri.file('/extra/workspace').fsPath);
1611+
1612+
// And still readable via the API
1613+
const result = await store.getAdditionalWorkspaces('session-crash');
1614+
expect(result).toHaveLength(1);
1615+
store.dispose();
1616+
});
1617+
});
1618+
14121619
// ──────────────────────────────────────────────────────────────────────────
14131620
// Constructor & edge cases
14141621
// ──────────────────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)