mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-11 03:38:15 +00:00
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:
parent
18894daad2
commit
b19ab45ba8
7 changed files with 101 additions and 234 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue