From b6f662f8523306ad7cff6c0d56d16a6298f25f82 Mon Sep 17 00:00:00 2001 From: daniel-lxs Date: Mon, 19 Jan 2026 22:46:14 -0500 Subject: [PATCH] feat: add task storage size display to settings - Add storage usage display showing total size and task count - Add calculateTaskStorageSize utility to compute global storage folder sizes - Add getTaskStorageSize message handler to communicate storage info to webview - Update About settings section to display storage usage with refresh button - Add taskHistoryStorage translations for all 18 supported languages --- packages/types/src/global-settings.ts | 8 + packages/types/src/vscode-extension-host.ts | 10 + src/core/webview/ClineProvider.ts | 5 + src/core/webview/webviewMessageHandler.ts | 21 ++ src/utils/__tests__/task-storage-size.spec.ts | 237 ++++++++++++++++++ src/utils/task-storage-size.ts | 138 ++++++++++ webview-ui/src/components/settings/About.tsx | 85 ++++++- .../src/components/settings/SettingsView.tsx | 11 + 8 files changed, 513 insertions(+), 2 deletions(-) create mode 100644 src/utils/__tests__/task-storage-size.spec.ts create mode 100644 src/utils/task-storage-size.ts diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index d1266b3432..681324fe7a 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -183,6 +183,14 @@ export const globalSettingsSchema = z.object({ includeTaskHistoryInEnhance: z.boolean().optional(), // Auto-delete task history on extension reload. "never" | "90" | "60" | "30" | "7" | "3" taskHistoryRetention: z.union([z.enum(["never", "90", "60", "30", "7", "3"]), z.number()]).optional(), + // Calculated task history storage size info for the Settings > About page + taskHistorySize: z + .object({ + totalBytes: z.number(), + taskCount: z.number(), + formattedSize: z.string(), + }) + .optional(), historyPreviewCollapsed: z.boolean().optional(), reasoningBlockCollapsed: z.boolean().optional(), /** diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 9e55fab176..5a6bae36fe 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -396,6 +396,15 @@ export type ExtensionState = Pick< marketplaceInstalledMetadata?: { project: Record; global: Record } profileThresholds: Record hasOpenedModeSelector: boolean + /** Task history storage size info for the Settings > About page */ + 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[] lastShownAnnouncementId?: string @@ -593,6 +602,7 @@ export interface WebviewMessage { | "requestModes" | "switchMode" | "debugSetting" + | "refreshTaskHistorySize" // Worktree messages | "listWorktrees" | "createWorktree" diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 64ad5a0640..bbe8d388a7 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2054,6 +2054,7 @@ export class ClineProvider enterBehavior, cloudUserInfo, taskHistoryRetention, + taskHistorySize, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, @@ -2239,6 +2240,8 @@ export class ClineProvider includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, // Task history retention setting for About tab dropdown taskHistoryRetention: taskHistoryRetention ?? "never", + // Task history storage size info for the Settings > About page + taskHistorySize, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, @@ -2456,6 +2459,8 @@ export class ClineProvider customCondensingPrompt: stateValues.customCondensingPrompt, // Task history retention selection taskHistoryRetention: stateValues.taskHistoryRetention ?? "never", + // Task history storage size info + taskHistorySize: stateValues.taskHistorySize, codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 9adbbbf003..2042ff2fb6 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -532,6 +532,9 @@ export const webviewMessageHandler = async ( TelemetryService.instance.updateTelemetryState(isOptedIn) }) + // Note: Task history storage size calculation is triggered on-demand when the About + // settings tab is opened (via "refreshTaskHistorySize" message), not on webview launch. + provider.isViewLaunched = true break case "newTask": @@ -3343,6 +3346,24 @@ export const webviewMessageHandler = async ( break } + case "refreshTaskHistorySize": { + // Refresh the task history storage size calculation + try { + const { calculateTaskStorageSize } = await import("../../utils/task-storage-size") + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const sizeInfo = await calculateTaskStorageSize(globalStoragePath) + + // Update state and notify webview + await provider.contextProxy.setValue("taskHistorySize", sizeInfo) + await provider.postStateToWebview() + } catch (error) { + provider.log( + `Error refreshing task history size: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } + /** * Git Worktree Management */ diff --git a/src/utils/__tests__/task-storage-size.spec.ts b/src/utils/__tests__/task-storage-size.spec.ts new file mode 100644 index 0000000000..f2ae2ad98f --- /dev/null +++ b/src/utils/__tests__/task-storage-size.spec.ts @@ -0,0 +1,237 @@ +import * as path from "path" +import { calculateTaskStorageSize, formatBytes } from "../task-storage-size" + +// 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), +})) + +describe("formatBytes", () => { + it("should format 0 bytes", () => { + expect(formatBytes(0)).toBe("0 B") + }) + + it("should format bytes less than 1 KB", () => { + expect(formatBytes(512)).toBe("512 B") + expect(formatBytes(1)).toBe("1 B") + }) + + it("should format kilobytes (0 decimal places)", () => { + expect(formatBytes(1024)).toBe("1 KB") + expect(formatBytes(2048)).toBe("2 KB") + expect(formatBytes(1536)).toBe("2 KB") // Rounds to nearest integer + expect(formatBytes(1280)).toBe("1 KB") // Rounds down + }) + + it("should format megabytes (2 decimal places)", () => { + expect(formatBytes(1048576)).toBe("1.00 MB") + expect(formatBytes(1572864)).toBe("1.50 MB") + expect(formatBytes(10485760)).toBe("10.00 MB") + }) + + it("should format gigabytes (2 decimal places)", () => { + expect(formatBytes(1073741824)).toBe("1.00 GB") + expect(formatBytes(2147483648)).toBe("2.00 GB") + }) + + it("should format terabytes (2 decimal places)", () => { + expect(formatBytes(1099511627776)).toBe("1.00 TB") + }) + + it("should handle decimal precision correctly", () => { + expect(formatBytes(1234567)).toBe("1.18 MB") + expect(formatBytes(123456789)).toBe("117.74 MB") + }) +}) + +describe("calculateTaskStorageSize", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should return zeros 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 () => { + 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 }) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + // Return task directories + return Promise.resolve([ + { name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { name: "task-2", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { name: "task-3", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + ]) + } + // Task subdirectories are empty + return Promise.resolve([]) + }) + + const result = await calculateTaskStorageSize("/global/storage") + + 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 () => { + 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: "some-file.txt", + 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) + }) +}) diff --git a/src/utils/task-storage-size.ts b/src/utils/task-storage-size.ts new file mode 100644 index 0000000000..669b6d733d --- /dev/null +++ b/src/utils/task-storage-size.ts @@ -0,0 +1,138 @@ +import * as path from "path" +import * as fs from "fs/promises" +import type { Dirent, Stats } from "fs" + +import { getStorageBasePath } from "./storage" + +/** + * Result of calculating task history storage size + */ +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 +} + +/** + * Formats bytes into a human-readable string with appropriate units. + * @param bytes Number of bytes + * @returns Formatted string (e.g., "12.34 MB", "5.67 GB") + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B" + + const units = ["B", "KB", "MB", "GB", "TB"] + const k = 1024 + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1) + const size = bytes / Math.pow(k, i) + + // Use 2 decimal places for MB and above, 0 for B and KB + const decimals = i >= 2 ? 2 : 0 + return `${size.toFixed(decimals)} ${units[i]}` +} + +/** + * 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.). + * + * 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. + * + * @param globalStoragePath VS Code global storage fsPath (context.globalStorageUri.fsPath) + * @param log Optional logger function for debugging + * @returns TaskStorageSizeResult with size info + */ +export async function calculateTaskStorageSize( + globalStoragePath: string, + log?: (message: string) => void, +): Promise { + const defaultResult: TaskStorageSizeResult = { + totalBytes: 0, + taskCount: 0, + formattedSize: "0 B", + } + + let basePath: string + + try { + basePath = await getStorageBasePath(globalStoragePath) + } catch (e) { + log?.(`[TaskStorageSize] Failed to resolve storage base path: ${e instanceof Error ? e.message : String(e)}`) + return defaultResult + } + + const tasksDir = path.join(basePath, "tasks") + + // Count task directories + let taskCount = 0 + try { + const entries = await fs.readdir(tasksDir, { withFileTypes: true }) + taskCount = entries.filter((d) => d.isDirectory()).length + } catch { + // Tasks directory doesn't exist yet + log?.(`[TaskStorageSize] Tasks directory not found at ${tasksDir}`) + 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 f86414189c..77be9d22fb 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -1,7 +1,19 @@ -import { HTMLAttributes } from "react" +import { HTMLAttributes, useState, useCallback, useEffect } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { Trans } from "react-i18next" -import { Download, Upload, TriangleAlert, Bug, Lightbulb, Shield, MessageCircle, MessagesSquare } from "lucide-react" +import { + Download, + Upload, + TriangleAlert, + Bug, + Lightbulb, + Shield, + MessageCircle, + MessagesSquare, + RefreshCw, + HardDrive, + Loader2, +} from "lucide-react" import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" import type { TelemetrySetting } from "@roo-code/types" @@ -16,6 +28,12 @@ import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" +type TaskHistorySize = { + totalBytes: number + taskCount: number + formattedSize: string +} + type AboutProps = HTMLAttributes & { telemetrySetting: TelemetrySetting setTelemetrySetting: (setting: TelemetrySetting) => void @@ -23,6 +41,7 @@ type AboutProps = HTMLAttributes & { setDebug?: (debug: boolean) => void taskHistoryRetention: "never" | "90" | "60" | "30" | "7" | "3" setTaskHistoryRetention: (value: "never" | "90" | "60" | "30" | "7" | "3") => void + taskHistorySize?: TaskHistorySize } export const About = ({ @@ -32,10 +51,46 @@ export const About = ({ setDebug, taskHistoryRetention, setTaskHistoryRetention, + taskHistorySize, className, ...props }: AboutProps) => { const { t } = useAppTranslation() + const [isRefreshing, setIsRefreshing] = useState(false) + const [cachedSize, setCachedSize] = useState(taskHistorySize) + + // Update cached size when taskHistorySize changes and reset refreshing state + useEffect(() => { + if (taskHistorySize) { + setCachedSize(taskHistorySize) + setIsRefreshing(false) + } + }, [taskHistorySize]) + + const handleRefreshStorageSize = useCallback(() => { + setIsRefreshing(true) + vscode.postMessage({ type: "refreshTaskHistorySize" }) + }, []) + + const getStorageDisplayText = (): string => { + // Use cached size if available, otherwise show "Calculating" only if no cached value + const displaySize = taskHistorySize || cachedSize + if (!displaySize) { + return t("settings:taskHistoryStorage.calculating") + } + 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.format", { + size: displaySize.formattedSize, + count: displaySize.taskCount, + }) + } return (
@@ -173,6 +228,32 @@ export const About = ({
+ +
+ + + {t("settings:taskHistoryStorage.label")}: {getStorageDisplayText()} + + +
+
+ (({ onDone, t taskHistoryRetention, } = cachedState + // taskHistorySize is read-only (not a saveable setting) so we use extensionState directly + const taskHistorySize = extensionState.taskHistorySize + const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) useEffect(() => { @@ -561,6 +564,13 @@ const SettingsView = forwardRef(({ onDone, t scrollToActiveTab() }, [activeTab, scrollToActiveTab]) + // Effect to trigger task history size calculation when About tab is opened + useEffect(() => { + if (activeTab === "about") { + vscode.postMessage({ type: "refreshTaskHistorySize" }) + } + }, [activeTab]) + // Effect to scroll when the webview becomes visible useLayoutEffect(() => { const handleMessage = (event: MessageEvent) => { @@ -962,6 +972,7 @@ const SettingsView = forwardRef(({ onDone, t setDebug={setDebug} taskHistoryRetention={normalizedTaskHistoryRetention} setTaskHistoryRetention={(value) => setCachedStateField("taskHistoryRetention", value)} + taskHistorySize={taskHistorySize} /> )}