Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit b19ab45

Browse files
committed
perf: replace storage size calculation with fast task count
- Remove expensive recursive getDirectorySize() that caused UI freeze with ~9000 tasks - Now only counts top-level task directories via single fs.readdir() call - Make task count on-demand (user must click refresh) instead of auto-trigger on settings load - Update types, i18n strings, and tests accordingly
1 parent 18894da commit b19ab45

7 files changed

Lines changed: 85 additions & 218 deletions

File tree

packages/types/src/global-settings.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,12 +191,12 @@ export const globalSettingsSchema = z.object({
191191
includeTaskHistoryInEnhance: z.boolean().optional(),
192192
// Auto-delete task history on extension reload.
193193
taskHistoryRetention: z.enum(TASK_HISTORY_RETENTION_OPTIONS).optional(),
194-
// Calculated task history storage size info for the Settings > About page
194+
// Calculated task history count for the Settings > About page
195+
// Note: Size calculation was removed for performance reasons - with large numbers of
196+
// tasks (e.g., 9000+), recursively stat'ing every file caused significant delays.
195197
taskHistorySize: z
196198
.object({
197-
totalBytes: z.number(),
198199
taskCount: z.number(),
199-
formattedSize: z.string(),
200200
})
201201
.optional(),
202202
historyPreviewCollapsed: z.boolean().optional(),

packages/types/src/vscode-extension-host.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -396,14 +396,13 @@ export type ExtensionState = Pick<
396396
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
397397
profileThresholds: Record<string, number>
398398
hasOpenedModeSelector: boolean
399-
/** Task history storage size info for the Settings > About page */
399+
/** Task history count for the Settings > About page
400+
* Note: Size calculation was removed for performance reasons - with large numbers of
401+
* tasks (e.g., 9000+), recursively stat'ing every file caused significant delays.
402+
*/
400403
taskHistorySize?: {
401-
/** Total size in bytes */
402-
totalBytes: number
403404
/** Number of task directories */
404405
taskCount: number
405-
/** Formatted size string (e.g., "12.34 MB") */
406-
formattedSize: string
407406
}
408407
openRouterImageApiKey?: string
409408
messageQueue?: QueuedMessage[]
Lines changed: 33 additions & 111 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import * as path from "path"
21
import { calculateTaskStorageSize, formatBytes } from "../task-storage-size"
32

43
// Mock storage to avoid VS Code config access during tests
@@ -8,13 +7,12 @@ vi.mock("../storage", () => ({
87

98
// Mock fs/promises
109
const mockReaddir = vi.fn()
11-
const mockStat = vi.fn()
1210

1311
vi.mock("fs/promises", () => ({
1412
readdir: (...args: unknown[]) => mockReaddir(...args),
15-
stat: (...args: unknown[]) => mockStat(...args),
1613
}))
1714

15+
// formatBytes is still exported for backwards compatibility but not used by calculateTaskStorageSize
1816
describe("formatBytes", () => {
1917
it("should format 0 bytes", () => {
2018
expect(formatBytes(0)).toBe("0 B")
@@ -58,33 +56,29 @@ describe("calculateTaskStorageSize", () => {
5856
vi.clearAllMocks()
5957
})
6058

61-
it("should return zeros when tasks directory does not exist", async () => {
59+
it("should return zero count when tasks directory does not exist", async () => {
6260
mockReaddir.mockRejectedValue(new Error("ENOENT: no such file or directory"))
6361

6462
const result = await calculateTaskStorageSize("/global/storage")
6563

6664
expect(result).toEqual({
67-
totalBytes: 0,
6865
taskCount: 0,
69-
formattedSize: "0 B",
7066
})
7167
})
7268

73-
it("should calculate size of empty tasks directory", async () => {
69+
it("should return zero count for empty tasks directory", async () => {
7470
mockReaddir.mockResolvedValue([])
7571

7672
const result = await calculateTaskStorageSize("/global/storage")
7773

7874
expect(result).toEqual({
79-
totalBytes: 0,
8075
taskCount: 0,
81-
formattedSize: "0 B",
8276
})
8377
})
8478

8579
it("should count task directories correctly", async () => {
8680
// Mock the tasks directory read
87-
mockReaddir.mockImplementation((dirPath: string, options?: { withFileTypes: boolean }) => {
81+
mockReaddir.mockImplementation((dirPath: string) => {
8882
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
8983
if (pathStr.endsWith("tasks")) {
9084
// Return task directories
@@ -94,7 +88,6 @@ describe("calculateTaskStorageSize", () => {
9488
{ name: "task-3", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
9589
])
9690
}
97-
// Task subdirectories are empty
9891
return Promise.resolve([])
9992
})
10093

@@ -103,140 +96,69 @@ describe("calculateTaskStorageSize", () => {
10396
expect(result.taskCount).toBe(3)
10497
})
10598

106-
it("should calculate total size including files", async () => {
107-
// Mock the tasks directory read
99+
it("should only count directories, not files in tasks folder", async () => {
108100
mockReaddir.mockImplementation((dirPath: string) => {
109101
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
110102
if (pathStr.endsWith("tasks")) {
111103
return Promise.resolve([
112104
{ name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
113-
])
114-
}
115-
if (pathStr.includes("task-1")) {
116-
return Promise.resolve([
117-
{ name: "file1.txt", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
118-
{ name: "file2.json", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
119-
])
120-
}
121-
return Promise.resolve([])
122-
})
123-
124-
mockStat.mockImplementation((filePath: string) => {
125-
if (filePath.includes("file1.txt")) {
126-
return Promise.resolve({ size: 1024 })
127-
}
128-
if (filePath.includes("file2.json")) {
129-
return Promise.resolve({ size: 2048 })
130-
}
131-
return Promise.resolve({ size: 0 })
132-
})
133-
134-
const result = await calculateTaskStorageSize("/global/storage")
135-
136-
expect(result.totalBytes).toBe(3072)
137-
expect(result.formattedSize).toBe("3 KB")
138-
expect(result.taskCount).toBe(1)
139-
})
140-
141-
it("should handle nested directories (like checkpoints)", async () => {
142-
mockReaddir.mockImplementation((dirPath: string) => {
143-
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
144-
if (pathStr.endsWith("tasks")) {
145-
return Promise.resolve([
146-
{ name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
147-
])
148-
}
149-
if (pathStr.endsWith("task-1") && !pathStr.includes("checkpoints")) {
150-
return Promise.resolve([
105+
{ name: "task-2", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
151106
{
152-
name: "api_conversation.json",
107+
name: "some-file.txt",
153108
isDirectory: () => false,
154109
isFile: () => true,
155110
isSymbolicLink: () => false,
156-
},
157-
{ name: "checkpoints", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
158-
])
159-
}
160-
if (pathStr.includes("checkpoints")) {
161-
return Promise.resolve([
111+
}, // Should not count as task
162112
{
163-
name: "checkpoint-1.json",
113+
name: "another-file.json",
164114
isDirectory: () => false,
165115
isFile: () => true,
166116
isSymbolicLink: () => false,
167-
},
117+
}, // Should not count as task
168118
])
169119
}
170120
return Promise.resolve([])
171121
})
172122

173-
mockStat.mockImplementation((filePath: string) => {
174-
if (filePath.includes("api_conversation.json")) {
175-
return Promise.resolve({ size: 5000 })
176-
}
177-
if (filePath.includes("checkpoint-1.json")) {
178-
return Promise.resolve({ size: 10000 })
179-
}
180-
return Promise.resolve({ size: 0 })
181-
})
182-
183123
const result = await calculateTaskStorageSize("/global/storage")
184124

185-
expect(result.totalBytes).toBe(15000)
186-
expect(result.taskCount).toBe(1)
125+
// Only directories count as tasks
126+
expect(result.taskCount).toBe(2)
187127
})
188128

189-
it("should handle stat errors gracefully", async () => {
129+
it("should handle large task counts efficiently (does not recurse into subdirectories)", async () => {
130+
// Simulate 9000 task directories - this should be fast since we don't recurse
131+
const manyTasks = Array.from({ length: 9000 }, (_, i) => ({
132+
name: `task-${i}`,
133+
isDirectory: () => true,
134+
isFile: () => false,
135+
isSymbolicLink: () => false,
136+
}))
137+
190138
mockReaddir.mockImplementation((dirPath: string) => {
191139
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
192140
if (pathStr.endsWith("tasks")) {
193-
return Promise.resolve([
194-
{ name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
195-
])
141+
return Promise.resolve(manyTasks)
196142
}
197-
return Promise.resolve([
198-
{ name: "broken-file.txt", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
199-
])
143+
return Promise.resolve([])
200144
})
201145

202-
mockStat.mockRejectedValue(new Error("Permission denied"))
203-
146+
const startTime = Date.now()
204147
const result = await calculateTaskStorageSize("/global/storage")
148+
const elapsed = Date.now() - startTime
205149

206-
// Should still return a result, just with 0 bytes for the failed stat
207-
expect(result.taskCount).toBe(1)
208-
expect(result.totalBytes).toBe(0)
150+
expect(result.taskCount).toBe(9000)
151+
// Should complete quickly since we're not recursing into directories
152+
expect(elapsed).toBeLessThan(100) // Should be nearly instant
209153
})
210154

211-
it("should handle mixed files and directories in tasks folder", async () => {
212-
mockReaddir.mockImplementation((dirPath: string) => {
213-
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
214-
if (pathStr.endsWith("tasks")) {
215-
return Promise.resolve([
216-
{ name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
217-
{
218-
name: "some-file.txt",
219-
isDirectory: () => false,
220-
isFile: () => true,
221-
isSymbolicLink: () => false,
222-
}, // Should not count as task
223-
])
224-
}
225-
return Promise.resolve([])
226-
})
227-
228-
mockStat.mockImplementation((filePath: string) => {
229-
if (filePath.includes("some-file.txt")) {
230-
return Promise.resolve({ size: 100 })
231-
}
232-
return Promise.resolve({ size: 0 })
233-
})
155+
it("should handle readdir errors gracefully", async () => {
156+
mockReaddir.mockRejectedValue(new Error("Permission denied"))
234157

235158
const result = await calculateTaskStorageSize("/global/storage")
236159

237-
// Only directories count as tasks
238-
expect(result.taskCount).toBe(1)
239-
// But file size should be included
240-
expect(result.totalBytes).toBe(100)
160+
expect(result).toEqual({
161+
taskCount: 0,
162+
})
241163
})
242164
})

src/utils/task-storage-size.ts

Lines changed: 12 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,42 @@
11
import * as path from "path"
22
import * as fs from "fs/promises"
3-
import type { Dirent, Stats } from "fs"
43

54
import { getStorageBasePath } from "./storage"
65
import { formatBytes } from "./formatBytes"
76

87
/**
9-
* Result of calculating task history storage size
8+
* Result of counting task history items.
9+
* Note: Size calculation was removed for performance reasons - with large numbers of
10+
* tasks (e.g., 9000+), recursively stat'ing every file caused significant delays.
1011
*/
1112
export interface TaskStorageSizeResult {
12-
/** Total size in bytes */
13-
totalBytes: number
1413
/** Number of task directories found */
1514
taskCount: number
16-
/** Formatted size string (e.g., "12.34 MB") */
17-
formattedSize: string
1815
}
1916

2017
// Re-export for backwards compatibility with existing imports/tests.
2118
export { formatBytes }
2219

2320
/**
24-
* Recursively calculates the total size of a directory.
25-
* @param dirPath Path to the directory
26-
* @returns Total size in bytes
27-
*/
28-
async function getDirectorySize(dirPath: string, depth: number = 0): Promise<number> {
29-
let totalSize = 0
30-
31-
// Safety check: prevent infinite recursion by limiting depth
32-
if (depth > 50) {
33-
return 0
34-
}
35-
36-
try {
37-
const entries: Dirent[] = await fs.readdir(dirPath, { withFileTypes: true })
38-
39-
// Process entries in parallel for better performance
40-
const sizes = await Promise.all(
41-
entries.map(async (entry) => {
42-
const entryPath = path.join(dirPath, entry.name)
43-
44-
try {
45-
// Check for symlinks to prevent infinite loops
46-
if (entry.isSymbolicLink()) {
47-
return 0
48-
}
49-
50-
if (entry.isDirectory()) {
51-
return await getDirectorySize(entryPath, depth + 1)
52-
} else if (entry.isFile()) {
53-
const stat: Stats = await fs.stat(entryPath)
54-
return stat.size
55-
}
56-
} catch {
57-
// Ignore errors for individual entries (permission issues, deleted files, etc.)
58-
}
59-
60-
return 0
61-
}),
62-
)
63-
64-
totalSize = sizes.reduce((acc, size) => acc + size, 0)
65-
} catch {
66-
// Directory doesn't exist or can't be read
67-
}
68-
69-
return totalSize
70-
}
71-
72-
/**
73-
* Calculates the total storage size used by task history.
74-
* This includes all files in the tasks/ directory (task data, checkpoints, etc.).
21+
* Counts the number of task directories in task history storage.
7522
*
76-
* This function is designed to be non-blocking and safe for background execution.
77-
* Errors are handled gracefully and will return 0 bytes if the directory doesn't exist
78-
* or can't be read.
23+
* This function is designed to be fast and non-blocking - it only counts
24+
* top-level directories without recursively walking the file tree.
25+
*
26+
* Note: Size calculation was intentionally removed because with large task counts
27+
* (e.g., 9000+), the recursive stat calls caused significant performance issues
28+
* and blocked the extension UI.
7929
*
8030
* @param globalStoragePath VS Code global storage fsPath (context.globalStorageUri.fsPath)
8131
* @param log Optional logger function for debugging
82-
* @returns TaskStorageSizeResult with size info
32+
* @returns TaskStorageSizeResult with task count
8333
*/
8434
export async function calculateTaskStorageSize(
8535
globalStoragePath: string,
8636
log?: (message: string) => void,
8737
): Promise<TaskStorageSizeResult> {
8838
const defaultResult: TaskStorageSizeResult = {
89-
totalBytes: 0,
9039
taskCount: 0,
91-
formattedSize: "0 B",
9240
}
9341

9442
let basePath: string
@@ -102,7 +50,7 @@ export async function calculateTaskStorageSize(
10250

10351
const tasksDir = path.join(basePath, "tasks")
10452

105-
// Count task directories
53+
// Count task directories - this is a fast O(1) readdir operation
10654
let taskCount = 0
10755
try {
10856
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
@@ -113,12 +61,7 @@ export async function calculateTaskStorageSize(
11361
return defaultResult
11462
}
11563

116-
// Calculate total size
117-
const totalBytes = await getDirectorySize(tasksDir)
118-
11964
return {
120-
totalBytes,
12165
taskCount,
122-
formattedSize: formatBytes(totalBytes),
12366
}
12467
}

0 commit comments

Comments
 (0)