Skip to content

Commit f46148e

Browse files
authored
refactor: add formatBytes helper (#7982)
1 parent 17771a6 commit f46148e

8 files changed

Lines changed: 226 additions & 91 deletions

File tree

web-app/src/containers/ChatInput.tsx

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import TextareaAutosize from 'react-textarea-autosize'
2-
import { cn } from '@/lib/utils'
2+
import { cn, formatBytes } from '@/lib/utils'
33
import { usePrompt } from '@/hooks/usePrompt'
44
import { useThreads } from '@/hooks/useThreads'
55
import { useCallback, useEffect, useMemo, useRef, useState, memo } from 'react'
@@ -910,18 +910,6 @@ const ChatInput = memo(function ChatInput({
910910
}
911911
}
912912

913-
const formatBytes = (bytes?: number): string => {
914-
if (!bytes || bytes <= 0) return ''
915-
const units = ['B', 'KB', 'MB', 'GB']
916-
let i = 0
917-
let val = bytes
918-
while (val >= 1024 && i < units.length - 1) {
919-
val /= 1024
920-
i++
921-
}
922-
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
923-
}
924-
925913
const hashBase64 = async (base64: string): Promise<string> => {
926914
const binary = atob(base64)
927915
const bytes = new Uint8Array(binary.length)
@@ -1517,7 +1505,10 @@ const ChatInput = memo(function ChatInput({
15171505
? `.${ext}`
15181506
: 'document'}
15191507
{att.size
1520-
? ` · ${formatBytes(att.size)}`
1508+
? ` · ${formatBytes(att.size, {
1509+
decimals: (_, unit) =>
1510+
unit === 'B' ? 0 : 1,
1511+
})}`
15211512
: ''}
15221513
</div>
15231514
</div>

web-app/src/containers/DownloadManegement.tsx

Lines changed: 54 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useNavigate } from '@tanstack/react-router'
1616
import { route } from '@/constants/routes'
1717
import { DownloadIcon } from 'lucide-react'
1818
import { Button } from '@/components/ui/button'
19+
import { formatBytes } from '@/lib/utils'
1920

2021
export function DownloadManagement() {
2122
const { t } = useTranslation()
@@ -352,11 +353,6 @@ export function DownloadManagement() {
352353
onAppUpdateDownloadError,
353354
])
354355

355-
function renderGB(bytes: number): string {
356-
const gb = bytes / 1024 ** 3
357-
return ((gb * 100) / 100).toFixed(2)
358-
}
359-
360356
return (
361357
<>
362358
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
@@ -420,7 +416,15 @@ export function DownloadManagement() {
420416
%
421417
</p>
422418
<p className="text-xs">
423-
{`${renderGB(appUpdateState.downloadedBytes)} / ${renderGB(appUpdateState.totalBytes)}`}{' '}
419+
{`${formatBytes(appUpdateState.downloadedBytes, {
420+
hideUnit: true,
421+
minUnit: 'GB',
422+
decimals: 2,
423+
})} / ${formatBytes(appUpdateState.totalBytes, {
424+
hideUnit: true,
425+
minUnit: 'GB',
426+
decimals: 2,
427+
})}`}{' '}
424428
GB
425429
</p>
426430
</div>
@@ -438,34 +442,34 @@ export function DownloadManagement() {
438442
</p>
439443
<div className="shrink-0 flex items-center space-x-0.5">
440444
<Button variant="secondary" size="icon-xs" onClick={() => {
441-
// TODO: Consolidate cancellation logic
442-
if (download.id.startsWith('llamacpp') || download.id.startsWith('mlx')) {
443-
const downloadManager =
444-
window.core.extensionManager.getByName(
445-
'@janhq/download-extension'
446-
)
447-
downloadManager.cancelDownload(download.id)
448-
} else {
449-
serviceHub
450-
.models()
451-
.abortDownload(download.name)
452-
.then(() => {
453-
toast.info(
454-
t('common:toast.downloadCancelled.title'),
455-
{
456-
id: 'cancel-download',
457-
description: t(
458-
'common:toast.downloadCancelled.description'
459-
),
460-
}
461-
)
462-
if (downloadProcesses.length === 0) {
463-
setIsPopoverOpen(false)
445+
// TODO: Consolidate cancellation logic
446+
if (download.id.startsWith('llamacpp') || download.id.startsWith('mlx')) {
447+
const downloadManager =
448+
window.core.extensionManager.getByName(
449+
'@janhq/download-extension'
450+
)
451+
downloadManager.cancelDownload(download.id)
452+
} else {
453+
serviceHub
454+
.models()
455+
.abortDownload(download.name)
456+
.then(() => {
457+
toast.info(
458+
t('common:toast.downloadCancelled.title'),
459+
{
460+
id: 'cancel-download',
461+
description: t(
462+
'common:toast.downloadCancelled.description'
463+
),
464464
}
465-
})
466-
}
467-
setIsPopoverOpen(false)
468-
}} >
465+
)
466+
if (downloadProcesses.length === 0) {
467+
setIsPopoverOpen(false)
468+
}
469+
})
470+
}
471+
setIsPopoverOpen(false)
472+
}} >
469473
<IconX
470474
size={16}
471475
className="text-muted-foreground cursor-pointer"
@@ -489,18 +493,30 @@ export function DownloadManagement() {
489493
</p>
490494
<p className="text-xs">
491495
{download.total > 0
492-
? `${renderGB(download.current)} / ${renderGB(download.total)} GB`
493-
: download.current > 0
494-
? `${renderGB(download.current)} GB`
495-
: ''}
496+
? `${formatBytes(download.current, {
497+
hideUnit: true,
498+
minUnit: 'GB',
499+
decimals: 2,
500+
})} / ${formatBytes(download.total, {
501+
hideUnit: true,
502+
minUnit: 'GB',
503+
decimals: 2,
504+
})} GB`
505+
: download.current > 0 ?
506+
`${formatBytes(download.current, {
507+
hideUnit: true,
508+
minUnit: 'GB',
509+
decimals: 2,
510+
})} GB` : ''
511+
}
496512
</p>
497513
</div>
498514
</div>
499515
</div>
500516
))}
501517
</div>
502518
</>
503-
) : (
519+
) : (
504520
<div className="px-3 py-8 flex flex-col items-center justify-center text-center space-y-2">
505521
<DownloadIcon className="text-muted-foreground/50 size-6" />
506522
<p className="text-muted-foreground leading-normal">

web-app/src/containers/ProjectFiles.tsx

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
TooltipContent,
99
TooltipTrigger,
1010
} from '@/components/ui/tooltip'
11-
import { cn } from '@/lib/utils'
11+
import { cn, formatBytes } from '@/lib/utils'
1212
import { toast } from 'sonner'
1313
import { useServiceHub } from '@/hooks/useServiceHub'
1414
import { createDocumentAttachment, type Attachment } from '@/types/attachment'
@@ -31,18 +31,6 @@ type ProjectFile = {
3131
chunk_count: number
3232
}
3333

34-
function formatBytes(bytes?: number): string {
35-
if (!bytes || bytes <= 0) return ''
36-
const units = ['B', 'KB', 'MB', 'GB']
37-
let i = 0
38-
let val = bytes
39-
while (val >= 1024 && i < units.length - 1) {
40-
val /= 1024
41-
i++
42-
}
43-
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
44-
}
45-
4634
const SUPPORTED_EXTENSIONS = [
4735
// Documents
4836
'pdf',
@@ -595,7 +583,11 @@ export default function ProjectFiles({ projectId, lng }: ProjectFilesProps) {
595583
</TooltipContent>
596584
</Tooltip>
597585
<p className="text-xs text-muted-foreground">
598-
{file.size ? formatBytes(file.size) : ''}
586+
{file.size
587+
? formatBytes(file.size, {
588+
decimals: (_, unit) => (unit === 'B' ? 0 : 1),
589+
})
590+
: ''}
599591
{file.chunk_count > 0 &&
600592
` · ${t('common:files.chunksCount', { count: file.chunk_count })}`}
601593
</p>

web-app/src/containers/SetupScreen.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { useLatestJanModel } from '@/hooks/useLatestJanModel'
1212
import { toast } from 'sonner'
1313
import { Button } from '@/components/ui/button'
1414
import { IconEye, IconSquareCheck } from '@tabler/icons-react'
15-
import { cn } from '@/lib/utils'
15+
import { cn, formatBytes } from '@/lib/utils'
1616
import { useGeneralSetting } from '@/hooks/useGeneralSetting'
1717
import HeaderPage from './HeaderPage'
1818

@@ -254,12 +254,6 @@ function SetupScreen() {
254254
}
255255
}, [defaultVariant, downloadProcesses])
256256

257-
const formatBytes = (bytes: number) => {
258-
if (bytes === 0) return '0'
259-
const gb = bytes / (1024 * 1024 * 1024)
260-
return gb.toFixed(1)
261-
}
262-
263257
const isDownloaded = useMemo(() => {
264258
if (!defaultVariant) return false
265259
return llamaProvider?.models.some(
@@ -461,7 +455,20 @@ function SetupScreen() {
461455
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
462456
/>
463457
</svg>
464-
<span>{formatBytes(downloadedSize.current)} / {formatBytes(downloadedSize.total)}GB</span>
458+
<span>
459+
{formatBytes(downloadedSize.current, {
460+
hideUnit: true,
461+
minUnit: 'GB',
462+
decimals: (value) => (value === 0 ? 0 : 1),
463+
})}{' '}
464+
/{' '}
465+
{formatBytes(downloadedSize.total, {
466+
hideUnit: true,
467+
minUnit: 'GB',
468+
decimals: (value) => (value === 0 ? 0 : 1),
469+
})}
470+
GB
471+
</span>
465472
</div>
466473
)}
467474

web-app/src/containers/dialogs/AttachmentIngestionDialog.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,7 @@ import {
99
import { Button } from '@/components/ui/button'
1010
import { useAttachmentIngestionPrompt } from '@/hooks/useAttachmentIngestionPrompt'
1111
import { useTranslation } from '@/i18n'
12-
13-
const formatBytes = (bytes?: number) => {
14-
if (!bytes || bytes <= 0) return ''
15-
const units = ['B', 'KB', 'MB', 'GB']
16-
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
17-
const value = bytes / Math.pow(1024, exponent)
18-
return `${value.toFixed(value >= 10 || exponent === 0 ? 0 : 1)} ${units[exponent]}`
19-
}
12+
import { formatBytes } from '@/lib/utils'
2013

2114
export default function AttachmentIngestionDialog() {
2215
const { t } = useTranslation()
@@ -47,7 +40,12 @@ export default function AttachmentIngestionDialog() {
4740
{currentAttachment.name}
4841
</span>
4942
<span className="text-xs text-muted-foreground shrink-0">
50-
{formatBytes(currentAttachment.size)}
43+
{currentAttachment.size && currentAttachment.size > 0
44+
? formatBytes(currentAttachment.size, {
45+
decimals: (value, unit) =>
46+
unit === 'B' || value >= 10 ? 0 : 1,
47+
})
48+
: ''}
5149
</span>
5250
</div>
5351
</div>

web-app/src/lib/__tests__/utils.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
getProviderTitle,
55
getReadableLanguageName,
66
toGigabytes,
7+
formatBytes,
78
formatMegaBytes,
89
formatDuration,
910
getModelDisplayName,
@@ -149,6 +150,80 @@ describe('formatMegaBytes', () => {
149150
})
150151
})
151152

153+
describe('formatBytes', () => {
154+
it('returns fallback for undefined or non-finite input', () => {
155+
expect(formatBytes(undefined)).toBe('')
156+
expect(formatBytes(undefined, { fallback: 'N/A' })).toBe('N/A')
157+
expect(formatBytes(Number.NaN, { fallback: '-' })).toBe('-')
158+
expect(formatBytes(Number.POSITIVE_INFINITY, { fallback: 'INF' })).toBe(
159+
'INF'
160+
)
161+
})
162+
163+
it('formats bytes with default options', () => {
164+
expect(formatBytes(0)).toBe('0.0 B')
165+
expect(formatBytes(512)).toBe('512.0 B')
166+
expect(formatBytes(1536)).toBe('1.5 KB')
167+
expect(formatBytes(1024 ** 2 * 2.25)).toBe('2.3 MB')
168+
expect(formatBytes(1024 ** 3 * 3)).toBe('3.0 GB')
169+
})
170+
171+
it('uses 1024 boundaries for unit transitions', () => {
172+
expect(formatBytes(1023)).toBe('1023.0 B')
173+
expect(formatBytes(1024)).toBe('1.0 KB')
174+
expect(formatBytes(1024 ** 2)).toBe('1.0 MB')
175+
expect(formatBytes(1024 ** 3)).toBe('1.0 GB')
176+
})
177+
178+
it('supports numeric decimals option', () => {
179+
expect(formatBytes(1536, { decimals: 2 })).toBe('1.50 KB')
180+
expect(formatBytes(1536, { decimals: 0 })).toBe('2 KB')
181+
expect(formatBytes(1536, { decimals: 3 })).toBe('1.500 KB')
182+
})
183+
184+
it('clamps and truncates decimal precision into toFixed range', () => {
185+
expect(formatBytes(1536, { decimals: -5 })).toBe('2 KB')
186+
expect(formatBytes(1536, { decimals: 2.9 })).toBe('1.50 KB')
187+
expect(formatBytes(1536, { decimals: 999 })).toBe(
188+
'1.50000000000000000000 KB'
189+
)
190+
})
191+
192+
it('supports function-based decimals per unit', () => {
193+
const decimals = vi.fn((value: number, unit: 'B' | 'KB' | 'MB' | 'GB') => {
194+
if (unit === 'B') return 0
195+
if (unit === 'KB') return value < 2 ? 3 : 1
196+
if (unit === 'MB') return 2
197+
return 4
198+
})
199+
200+
expect(formatBytes(900, { decimals })).toBe('900 B')
201+
expect(formatBytes(1536, { decimals })).toBe('1.500 KB')
202+
expect(formatBytes(4096, { decimals })).toBe('4.0 KB')
203+
expect(formatBytes(1024 ** 2 * 1.25, { decimals })).toBe('1.25 MB')
204+
expect(formatBytes(1024 ** 3 * 1.5, { decimals })).toBe('1.5000 GB')
205+
expect(decimals).toHaveBeenCalledTimes(5)
206+
})
207+
208+
it('supports separator and hideUnit options', () => {
209+
expect(formatBytes(1536, { separator: '' })).toBe('1.5KB')
210+
expect(formatBytes(1536, { separator: ' - ' })).toBe('1.5 - KB')
211+
expect(formatBytes(1536, { hideUnit: true })).toBe('1.5')
212+
expect(formatBytes(1536, { hideUnit: true, separator: ' / ' })).toBe('1.5')
213+
})
214+
215+
it('honors minUnit floor', () => {
216+
expect(formatBytes(1, { minUnit: 'KB', decimals: 4 })).toBe('0.0010 KB')
217+
expect(formatBytes(1024, { minUnit: 'MB', decimals: 4 })).toBe('0.0010 MB')
218+
expect(formatBytes(1024 ** 2, { minUnit: 'GB', decimals: 4 })).toBe(
219+
'0.0010 GB'
220+
)
221+
expect(formatBytes(1024 ** 2 * 2, { minUnit: 'KB', decimals: 2 })).toBe(
222+
'2.00 MB'
223+
)
224+
})
225+
})
226+
152227
describe('formatDuration', () => {
153228
it('formats milliseconds when duration is less than 1 second', () => {
154229
const start = Date.now()

0 commit comments

Comments
 (0)