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
This commit is contained in:
Hannes Rudolph 2026-01-26 14:01:12 -07:00
parent 18894daad2
commit b19ab45ba8
7 changed files with 101 additions and 234 deletions

View file

@ -191,12 +191,12 @@ export const globalSettingsSchema = z.object({
includeTaskHistoryInEnhance: z.boolean().optional(),
// Auto-delete task history on extension reload.
taskHistoryRetention: z.enum(TASK_HISTORY_RETENTION_OPTIONS).optional(),
// Calculated task history storage size info for the Settings > About page
// Calculated task history count for the Settings > About page
// Note: Size calculation was removed for performance reasons - with large numbers of
// tasks (e.g., 9000+), recursively stat'ing every file caused significant delays.
taskHistorySize: z
.object({
totalBytes: z.number(),
taskCount: z.number(),
formattedSize: z.string(),
})
.optional(),
historyPreviewCollapsed: z.boolean().optional(),

View file

@ -396,14 +396,13 @@ export type ExtensionState = Pick<
marketplaceInstalledMetadata?: { project: Record<string, any>; global: Record<string, any> }
profileThresholds: Record<string, number>
hasOpenedModeSelector: boolean
/** Task history storage size info for the Settings > About page */
/** Task history count for the Settings > About page
* Note: Size calculation was removed for performance reasons - with large numbers of
* tasks (e.g., 9000+), recursively stat'ing every file caused significant delays.
*/
taskHistorySize?: {
/** Total size in bytes */
totalBytes: number
/** Number of task directories */
taskCount: number
/** Formatted size string (e.g., "12.34 MB") */
formattedSize: string
}
openRouterImageApiKey?: string
messageQueue?: QueuedMessage[]

View file

@ -1,4 +1,3 @@
import * as path from "path"
import { calculateTaskStorageSize, formatBytes } from "../task-storage-size"
// Mock storage to avoid VS Code config access during tests
@ -8,13 +7,12 @@ vi.mock("../storage", () => ({
// Mock fs/promises
const mockReaddir = vi.fn()
const mockStat = vi.fn()
vi.mock("fs/promises", () => ({
readdir: (...args: unknown[]) => mockReaddir(...args),
stat: (...args: unknown[]) => mockStat(...args),
}))
// formatBytes is still exported for backwards compatibility but not used by calculateTaskStorageSize
describe("formatBytes", () => {
it("should format 0 bytes", () => {
expect(formatBytes(0)).toBe("0 B")
@ -58,33 +56,29 @@ describe("calculateTaskStorageSize", () => {
vi.clearAllMocks()
})
it("should return zeros when tasks directory does not exist", async () => {
it("should return zero count when tasks directory does not exist", async () => {
mockReaddir.mockRejectedValue(new Error("ENOENT: no such file or directory"))
const result = await calculateTaskStorageSize("/global/storage")
expect(result).toEqual({
totalBytes: 0,
taskCount: 0,
formattedSize: "0 B",
})
})
it("should calculate size of empty tasks directory", async () => {
it("should return zero count for empty tasks directory", async () => {
mockReaddir.mockResolvedValue([])
const result = await calculateTaskStorageSize("/global/storage")
expect(result).toEqual({
totalBytes: 0,
taskCount: 0,
formattedSize: "0 B",
})
})
it("should count task directories correctly", async () => {
// Mock the tasks directory read
mockReaddir.mockImplementation((dirPath: string, options?: { withFileTypes: boolean }) => {
mockReaddir.mockImplementation((dirPath: string) => {
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
if (pathStr.endsWith("tasks")) {
// Return task directories
@ -94,7 +88,6 @@ describe("calculateTaskStorageSize", () => {
{ name: "task-3", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
])
}
// Task subdirectories are empty
return Promise.resolve([])
})
@ -103,140 +96,69 @@ describe("calculateTaskStorageSize", () => {
expect(result.taskCount).toBe(3)
})
it("should calculate total size including files", async () => {
// Mock the tasks directory read
mockReaddir.mockImplementation((dirPath: string) => {
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
if (pathStr.endsWith("tasks")) {
return Promise.resolve([
{ name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
])
}
if (pathStr.includes("task-1")) {
return Promise.resolve([
{ name: "file1.txt", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: "file2.json", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
])
}
return Promise.resolve([])
})
mockStat.mockImplementation((filePath: string) => {
if (filePath.includes("file1.txt")) {
return Promise.resolve({ size: 1024 })
}
if (filePath.includes("file2.json")) {
return Promise.resolve({ size: 2048 })
}
return Promise.resolve({ size: 0 })
})
const result = await calculateTaskStorageSize("/global/storage")
expect(result.totalBytes).toBe(3072)
expect(result.formattedSize).toBe("3 KB")
expect(result.taskCount).toBe(1)
})
it("should handle nested directories (like checkpoints)", async () => {
mockReaddir.mockImplementation((dirPath: string) => {
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
if (pathStr.endsWith("tasks")) {
return Promise.resolve([
{ name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
])
}
if (pathStr.endsWith("task-1") && !pathStr.includes("checkpoints")) {
return Promise.resolve([
{
name: "api_conversation.json",
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
},
{ name: "checkpoints", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
])
}
if (pathStr.includes("checkpoints")) {
return Promise.resolve([
{
name: "checkpoint-1.json",
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
},
])
}
return Promise.resolve([])
})
mockStat.mockImplementation((filePath: string) => {
if (filePath.includes("api_conversation.json")) {
return Promise.resolve({ size: 5000 })
}
if (filePath.includes("checkpoint-1.json")) {
return Promise.resolve({ size: 10000 })
}
return Promise.resolve({ size: 0 })
})
const result = await calculateTaskStorageSize("/global/storage")
expect(result.totalBytes).toBe(15000)
expect(result.taskCount).toBe(1)
})
it("should handle stat errors gracefully", async () => {
mockReaddir.mockImplementation((dirPath: string) => {
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
if (pathStr.endsWith("tasks")) {
return Promise.resolve([
{ name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
])
}
return Promise.resolve([
{ name: "broken-file.txt", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
])
})
mockStat.mockRejectedValue(new Error("Permission denied"))
const result = await calculateTaskStorageSize("/global/storage")
// Should still return a result, just with 0 bytes for the failed stat
expect(result.taskCount).toBe(1)
expect(result.totalBytes).toBe(0)
})
it("should handle mixed files and directories in tasks folder", async () => {
it("should only count directories, not files in tasks folder", async () => {
mockReaddir.mockImplementation((dirPath: string) => {
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
if (pathStr.endsWith("tasks")) {
return Promise.resolve([
{ name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: "task-2", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{
name: "some-file.txt",
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
}, // Should not count as task
{
name: "another-file.json",
isDirectory: () => false,
isFile: () => true,
isSymbolicLink: () => false,
}, // Should not count as task
])
}
return Promise.resolve([])
})
mockStat.mockImplementation((filePath: string) => {
if (filePath.includes("some-file.txt")) {
return Promise.resolve({ size: 100 })
}
return Promise.resolve({ size: 0 })
})
const result = await calculateTaskStorageSize("/global/storage")
// Only directories count as tasks
expect(result.taskCount).toBe(1)
// But file size should be included
expect(result.totalBytes).toBe(100)
expect(result.taskCount).toBe(2)
})
it("should handle large task counts efficiently (does not recurse into subdirectories)", async () => {
// Simulate 9000 task directories - this should be fast since we don't recurse
const manyTasks = Array.from({ length: 9000 }, (_, i) => ({
name: `task-${i}`,
isDirectory: () => true,
isFile: () => false,
isSymbolicLink: () => false,
}))
mockReaddir.mockImplementation((dirPath: string) => {
const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath)
if (pathStr.endsWith("tasks")) {
return Promise.resolve(manyTasks)
}
return Promise.resolve([])
})
const startTime = Date.now()
const result = await calculateTaskStorageSize("/global/storage")
const elapsed = Date.now() - startTime
expect(result.taskCount).toBe(9000)
// Should complete quickly since we're not recursing into directories
expect(elapsed).toBeLessThan(100) // Should be nearly instant
})
it("should handle readdir errors gracefully", async () => {
mockReaddir.mockRejectedValue(new Error("Permission denied"))
const result = await calculateTaskStorageSize("/global/storage")
expect(result).toEqual({
taskCount: 0,
})
})
})

View file

@ -1,94 +1,42 @@
import * as path from "path"
import * as fs from "fs/promises"
import type { Dirent, Stats } from "fs"
import { getStorageBasePath } from "./storage"
import { formatBytes } from "./formatBytes"
/**
* Result of calculating task history storage size
* Result of counting task history items.
* Note: Size calculation was removed for performance reasons - with large numbers of
* tasks (e.g., 9000+), recursively stat'ing every file caused significant delays.
*/
export interface TaskStorageSizeResult {
/** Total size in bytes */
totalBytes: number
/** Number of task directories found */
taskCount: number
/** Formatted size string (e.g., "12.34 MB") */
formattedSize: string
}
// Re-export for backwards compatibility with existing imports/tests.
export { formatBytes }
/**
* Recursively calculates the total size of a directory.
* @param dirPath Path to the directory
* @returns Total size in bytes
*/
async function getDirectorySize(dirPath: string, depth: number = 0): Promise<number> {
let totalSize = 0
// Safety check: prevent infinite recursion by limiting depth
if (depth > 50) {
return 0
}
try {
const entries: Dirent[] = await fs.readdir(dirPath, { withFileTypes: true })
// Process entries in parallel for better performance
const sizes = await Promise.all(
entries.map(async (entry) => {
const entryPath = path.join(dirPath, entry.name)
try {
// Check for symlinks to prevent infinite loops
if (entry.isSymbolicLink()) {
return 0
}
if (entry.isDirectory()) {
return await getDirectorySize(entryPath, depth + 1)
} else if (entry.isFile()) {
const stat: Stats = await fs.stat(entryPath)
return stat.size
}
} catch {
// Ignore errors for individual entries (permission issues, deleted files, etc.)
}
return 0
}),
)
totalSize = sizes.reduce((acc, size) => acc + size, 0)
} catch {
// Directory doesn't exist or can't be read
}
return totalSize
}
/**
* Calculates the total storage size used by task history.
* This includes all files in the tasks/ directory (task data, checkpoints, etc.).
* Counts the number of task directories in task history storage.
*
* This function is designed to be non-blocking and safe for background execution.
* Errors are handled gracefully and will return 0 bytes if the directory doesn't exist
* or can't be read.
* This function is designed to be fast and non-blocking - it only counts
* top-level directories without recursively walking the file tree.
*
* Note: Size calculation was intentionally removed because with large task counts
* (e.g., 9000+), the recursive stat calls caused significant performance issues
* and blocked the extension UI.
*
* @param globalStoragePath VS Code global storage fsPath (context.globalStorageUri.fsPath)
* @param log Optional logger function for debugging
* @returns TaskStorageSizeResult with size info
* @returns TaskStorageSizeResult with task count
*/
export async function calculateTaskStorageSize(
globalStoragePath: string,
log?: (message: string) => void,
): Promise<TaskStorageSizeResult> {
const defaultResult: TaskStorageSizeResult = {
totalBytes: 0,
taskCount: 0,
formattedSize: "0 B",
}
let basePath: string
@ -102,7 +50,7 @@ export async function calculateTaskStorageSize(
const tasksDir = path.join(basePath, "tasks")
// Count task directories
// Count task directories - this is a fast O(1) readdir operation
let taskCount = 0
try {
const entries = await fs.readdir(tasksDir, { withFileTypes: true })
@ -113,12 +61,7 @@ export async function calculateTaskStorageSize(
return defaultResult
}
// Calculate total size
const totalBytes = await getDirectorySize(tasksDir)
return {
totalBytes,
taskCount,
formattedSize: formatBytes(totalBytes),
}
}

View file

@ -1,4 +1,4 @@
import { HTMLAttributes, useState, useCallback, useEffect, useRef } from "react"
import { HTMLAttributes, useState, useCallback, useEffect } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { Trans } from "react-i18next"
import {
@ -11,7 +11,7 @@ import {
MessageCircle,
MessagesSquare,
RefreshCw,
HardDrive,
FolderOpen,
Loader2,
} from "lucide-react"
import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
@ -29,9 +29,7 @@ import { Section } from "./Section"
import { SearchableSetting } from "./SearchableSetting"
type TaskHistorySize = {
totalBytes: number
taskCount: number
formattedSize: string
}
type AboutProps = HTMLAttributes<HTMLDivElement> & {
@ -58,7 +56,6 @@ export const About = ({
const { t } = useAppTranslation()
const [isRefreshing, setIsRefreshing] = useState(false)
const [cachedSize, setCachedSize] = useState<TaskHistorySize | undefined>(taskHistorySize)
const didRequestInitialSize = useRef(false)
// Update cached size when taskHistorySize changes and reset refreshing state
useEffect(() => {
@ -68,34 +65,27 @@ export const About = ({
}
}, [taskHistorySize])
// Trigger initial task history size calculation when this tab mounts
useEffect(() => {
if (didRequestInitialSize.current) return
didRequestInitialSize.current = true
vscode.postMessage({ type: "refreshTaskHistorySize" })
}, [])
// NOTE: No auto-trigger on mount - user must click refresh button
// This is intentional for performance with large task counts (e.g., 9000+ tasks)
const handleRefreshStorageSize = useCallback(() => {
const handleRefreshTaskCount = useCallback(() => {
setIsRefreshing(true)
vscode.postMessage({ type: "refreshTaskHistorySize" })
}, [])
const getStorageDisplayText = (): string => {
// Use cached size if available, otherwise show "Calculating" only if no cached value
const getTaskCountDisplayText = (): string => {
// Use cached size if available, otherwise prompt user to click refresh
const displaySize = taskHistorySize || cachedSize
if (!displaySize) {
return t("settings:taskHistoryStorage.calculating")
return t("settings:taskHistoryStorage.clickToCount")
}
if (displaySize.taskCount === 0) {
return t("settings:taskHistoryStorage.empty")
}
if (displaySize.taskCount === 1) {
return t("settings:taskHistoryStorage.formatSingular", {
size: displaySize.formattedSize,
})
return t("settings:taskHistoryStorage.countSingular")
}
return t("settings:taskHistoryStorage.format", {
size: displaySize.formattedSize,
return t("settings:taskHistoryStorage.count", {
count: displaySize.taskCount,
})
}
@ -237,19 +227,19 @@ export const About = ({
</SearchableSetting>
<SearchableSetting
settingId="about-task-history-storage"
settingId="about-task-history-count"
section="about"
label={t("settings:taskHistoryStorage.label")}
className="mt-4">
<div className="flex items-center gap-2">
<HardDrive className="size-4 text-vscode-descriptionForeground shrink-0" />
<FolderOpen className="size-4 text-vscode-descriptionForeground shrink-0" />
<span className="text-sm">
{t("settings:taskHistoryStorage.label")}: {getStorageDisplayText()}
{t("settings:taskHistoryStorage.label")}: {getTaskCountDisplayText()}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleRefreshStorageSize}
onClick={handleRefreshTaskCount}
disabled={isRefreshing}
className="h-6 w-6 p-0"
title={t("settings:taskHistoryStorage.refresh")}>

View file

@ -243,6 +243,20 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
}
}, [settingsImportedAt, extensionState])
// Sync taskHistoryRetention from extensionState when it changes and the user
// hasn't made local changes yet. This handles the race condition where
// cachedState is initialized before the initial state message arrives.
useEffect(() => {
if (!isChangeDetected && extensionState.taskHistoryRetention !== undefined) {
setCachedState((prev) => {
if (prev.taskHistoryRetention === extensionState.taskHistoryRetention) {
return prev // No change needed
}
return { ...prev, taskHistoryRetention: extensionState.taskHistoryRetention }
})
}
}, [extensionState.taskHistoryRetention, isChangeDetected])
const setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType> = useCallback((field, value) => {
setCachedState((prevState) => {
if (prevState[field] === value) {

View file

@ -919,13 +919,12 @@
}
},
"taskHistoryStorage": {
"label": "Storage usage",
"calculating": "Calculating...",
"format": "{{size}} ({{count}} tasks)",
"formatSingular": "{{size}} (1 task)",
"label": "Task count",
"clickToCount": "Click refresh to count",
"count": "{{count}} tasks",
"countSingular": "1 task",
"empty": "No tasks stored",
"refresh": "Refresh",
"error": "Unable to calculate"
"refresh": "Count tasks"
},
"footer": {
"telemetry": {