diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 50811d0527..5f4cbf0cd3 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -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(), diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 5a6bae36fe..77f6c5e34b 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -396,14 +396,13 @@ export type ExtensionState = Pick< marketplaceInstalledMetadata?: { project: Record; global: Record } profileThresholds: Record 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[] diff --git a/src/utils/__tests__/task-storage-size.spec.ts b/src/utils/__tests__/task-storage-size.spec.ts index cc7997b146..5d1a346347 100644 --- a/src/utils/__tests__/task-storage-size.spec.ts +++ b/src/utils/__tests__/task-storage-size.spec.ts @@ -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, + }) }) }) diff --git a/src/utils/task-storage-size.ts b/src/utils/task-storage-size.ts index cd16766735..91544b5533 100644 --- a/src/utils/task-storage-size.ts +++ b/src/utils/task-storage-size.ts @@ -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 { - 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 { 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), } } diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index e51df56ef5..f85582fc33 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -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 & { @@ -58,7 +56,6 @@ export const About = ({ const { t } = useAppTranslation() const [isRefreshing, setIsRefreshing] = useState(false) const [cachedSize, setCachedSize] = useState(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 = ({
- + - {t("settings:taskHistoryStorage.label")}: {getStorageDisplayText()} + {t("settings:taskHistoryStorage.label")}: {getTaskCountDisplayText()}