Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 55 additions & 43 deletions src/extension/agents/claude/node/claudeCodeSessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
@INativeEnvService private readonly _nativeEnvService: INativeEnvService
) { }

/**
* Read a directory, returning an empty array if the directory doesn't exist.
*/
private async _tryReadDirectory(dirUri: URI): Promise<[string, FileType][]> {
try {
return await this._fileSystem.readDirectory(dirUri);
} catch (e) {
switch (e.code) {
case 'FileNotFound':
case 'DirectoryNotFound':
case 'ENOENT':
break;
default:
this._logService.error(e, `[ClaudeCodeSessionService] Failed to read directory: ${dirUri}`);
break;
}
return [];
}
}

/**
* Collect messages from all sessions in all workspace folders.
* - Read all .jsonl files in the .claude/projects/<folder> dir
Expand Down Expand Up @@ -136,67 +156,59 @@ export class ClaudeCodeSessionService implements IClaudeCodeSessionService {
return null; // No cache entry
}

try {
const entries = await this._fileSystem.readDirectory(projectDirUri);
if (token.isCancellationRequested) {
return null;
}
const entries = await this._tryReadDirectory(projectDirUri);
if (entries.length === 0) {
return null; // Directory empty or gone, invalidate cache
}
if (token.isCancellationRequested) {
return null;
}

const currentFiles = new ResourceSet();
const currentFiles = new ResourceSet();

// Check if any .jsonl files have changed since our last cache
for (const [name, type] of entries) {
if (type !== FileType.File || !name.endsWith('.jsonl')) {
continue;
}
// Check if any .jsonl files have changed since our last cache
for (const [name, type] of entries) {
if (type !== FileType.File || !name.endsWith('.jsonl')) {
continue;
}

const fileUri = URI.joinPath(projectDirUri, name);
currentFiles.add(fileUri);
const fileUri = URI.joinPath(projectDirUri, name);
currentFiles.add(fileUri);

try {
const stat = await this._fileSystem.stat(fileUri);
const cachedMtime = this._fileMtimes.get(fileUri);
try {
const stat = await this._fileSystem.stat(fileUri);
const cachedMtime = this._fileMtimes.get(fileUri);

if (!cachedMtime || stat.mtime > cachedMtime) {
// File has changed or is new
return null;
}
} catch (e) {
// File might have been deleted, invalidate cache
if (!cachedMtime || stat.mtime > cachedMtime) {
// File has changed or is new
return null;
}
} catch (e) {
// File might have been deleted, invalidate cache
return null;
}
}

// Check if any previously cached files have been deleted
for (const cachedFileUri of this._fileMtimes.keys()) {
if (isEqualOrParent(cachedFileUri, projectDirUri) && cachedFileUri.path.endsWith('.jsonl')) {
if (!currentFiles.has(cachedFileUri)) {
// A previously cached file has been deleted
return null;
}
// Check if any previously cached files have been deleted
for (const cachedFileUri of this._fileMtimes.keys()) {
if (isEqualOrParent(cachedFileUri, projectDirUri) && cachedFileUri.path.endsWith('.jsonl')) {
if (!currentFiles.has(cachedFileUri)) {
// A previously cached file has been deleted
return null;
}
}

// All files are unchanged, return cached sessions
return this._sessionCache.get(projectDirUri) || null;
} catch (e) {
// Directory read failed, invalidate cache
this._logService.error(e, `[ClaudeCodeSessionLoader] Failed to check cache validity for: ${projectDirUri}`);
return null;
}

// All files are unchanged, return cached sessions
return this._sessionCache.get(projectDirUri) || null;
}

/**
* Load sessions from disk and update file modification time tracking
*/
private async _loadSessionsFromDisk(projectDirUri: URI, token: CancellationToken): Promise<readonly IClaudeCodeSession[]> {
let entries: [string, FileType][] = [];
try {
entries = await this._fileSystem.readDirectory(projectDirUri);
} catch (e) {
if (e.code !== 'FileNotFound') {
this._logService.error(e, `[ClaudeChatSessionItemProvider] ${e.code} Failed to read directory: ${projectDirUri}`);
}
const entries = await this._tryReadDirectory(projectDirUri);
if (entries.length === 0) {
return [];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -662,4 +662,44 @@ describe('ClaudeCodeSessionService', () => {
expect(spaceSlug).toBe('-Users-test-my-project');
});
});
});

describe('directory read error handling', () => {
function createErrorWithCode(code: string): Error {
const error = new Error(`Directory error: ${code}`);
(error as Error & { code: string }).code = code;
return error;
}

it('returns empty sessions when directory throws ENOENT error', async () => {
mockFs.mockError(dirUri, createErrorWithCode('ENOENT'));

const sessions = await service.getAllSessions(CancellationToken.None);

expect(sessions).toHaveLength(0);
});

it('returns empty sessions when directory throws FileNotFound error', async () => {
mockFs.mockError(dirUri, createErrorWithCode('FileNotFound'));

const sessions = await service.getAllSessions(CancellationToken.None);

expect(sessions).toHaveLength(0);
});

it('returns empty sessions when directory throws DirectoryNotFound error', async () => {
mockFs.mockError(dirUri, createErrorWithCode('DirectoryNotFound'));

const sessions = await service.getAllSessions(CancellationToken.None);

expect(sessions).toHaveLength(0);
});

it('returns empty sessions and logs error for unexpected directory errors', async () => {
mockFs.mockError(dirUri, createErrorWithCode('EACCES'));

const sessions = await service.getAllSessions(CancellationToken.None);

expect(sessions).toHaveLength(0);
});
});
});