Skip to content

Commit a93a8bb

Browse files
committed
Indexing: Size mismatch warning icon
- Always show both content and on-disk sizes in tooltips (no more collapsing when similar) - Add `CircleAlert` icon on folders where sizes differ by ≥50% and ≥200 MB - Add `listing.sizeMismatchWarning` setting to toggle the warning icon - Replace `sizesDifferSignificantly` with `hasSizeMismatch` (stricter thresholds)
1 parent 424a807 commit a93a8bb

8 files changed

Lines changed: 177 additions & 57 deletions

File tree

apps/desktop/src/lib/file-explorer/views/BriefList.svelte

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@
1818
refetchIconsForEntries,
1919
updateIndexSizesInPlace,
2020
} from './file-list-utils'
21-
import { buildDirSizeTooltip } from './full-list-utils'
22-
import { getRowHeight, formatFileSize } from '$lib/settings/reactive-settings.svelte'
21+
import { buildDirSizeTooltip, hasSizeMismatch } from './full-list-utils'
22+
import { getRowHeight, formatFileSize, getSizeMismatchWarning } from '$lib/settings/reactive-settings.svelte'
2323
import { getSetting } from '$lib/settings/settings-store'
2424
import { formatNumber, pluralize } from '../selection/selection-info-utils'
2525
import { isScanning, isAggregating } from '$lib/indexing/index-state.svelte'
@@ -432,21 +432,31 @@
432432
}
433433
})
434434
435+
// Size mismatch warning setting
436+
const showSizeMismatchWarning = $derived(getSizeMismatchWarning())
437+
435438
/** Build tooltip for a directory entry showing recursive size info. */
436439
function buildDirTooltip(file: FileEntry): string | { html: string } | undefined {
437440
if (!file.isDirectory) return undefined
438-
return (
439-
buildDirSizeTooltip(
440-
file.recursiveSize,
441-
file.recursivePhysicalSize,
442-
file.recursiveFileCount ?? 0,
443-
file.recursiveDirCount ?? 0,
444-
indexing,
445-
formatFileSize,
446-
formatNumber,
447-
pluralize,
448-
) || undefined
441+
const base = buildDirSizeTooltip(
442+
file.recursiveSize,
443+
file.recursivePhysicalSize,
444+
file.recursiveFileCount ?? 0,
445+
file.recursiveDirCount ?? 0,
446+
indexing,
447+
formatFileSize,
448+
formatNumber,
449+
pluralize,
449450
)
451+
if (!base) return undefined
452+
453+
// Prepend mismatch warning when applicable
454+
if (showSizeMismatchWarning && hasSizeMismatch(file.recursiveSize, file.recursivePhysicalSize)) {
455+
const baseHtml = typeof base === 'object' ? base.html : base
456+
return { html: 'Content and on-disk sizes differ significantly.<br><br>' + baseHtml }
457+
}
458+
459+
return base
450460
}
451461
452462
// Report visible range to parent for MCP state sync

apps/desktop/src/lib/file-explorer/views/FullList.svelte

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,19 @@
2626
buildDirSizeTooltip,
2727
buildFileSizeTooltip,
2828
getDisplaySize,
29+
hasSizeMismatch,
2930
} from './full-list-utils'
3031
import {
3132
getRowHeight,
3233
getIsCompactDensity,
3334
formatDateTime,
3435
formatFileSize,
3536
getSizeDisplayMode,
37+
getSizeMismatchWarning,
3638
} from '$lib/settings/reactive-settings.svelte'
3739
import { iconCacheCleared } from '$lib/icon-cache'
3840
import { tooltip } from '$lib/tooltip/tooltip'
39-
import { Hourglass } from '@lucide/svelte'
41+
import { Hourglass, CircleAlert } from '@lucide/svelte'
4042
import type { RenameState } from '../rename/rename-state.svelte'
4143
4244
interface Props {
@@ -129,6 +131,9 @@
129131
// Size display mode (smart/logical/physical)
130132
const sizeDisplayMode = $derived(getSizeDisplayMode())
131133
134+
// Size mismatch warning setting
135+
const showSizeMismatchWarning = $derived(getSizeMismatchWarning())
136+
132137
// Drive index state — show spinner while scanning OR aggregating (sizes aren't ready until aggregation finishes)
133138
const indexing = $derived(isScanning() || isAggregating())
134139
@@ -494,6 +499,30 @@
494499
><Hourglass size={12} color="var(--color-accent)" /></span
495500
>
496501
{/if}
502+
{#if showSizeMismatchWarning && hasSizeMismatch(file.recursiveSize, file.recursivePhysicalSize)}
503+
{@const dirTooltip = buildDirSizeTooltip(
504+
file.recursiveSize,
505+
file.recursivePhysicalSize,
506+
file.recursiveFileCount ?? 0,
507+
file.recursiveDirCount ?? 0,
508+
indexing,
509+
formatFileSize,
510+
formatNumber,
511+
pluralize,
512+
)}
513+
{@const dirTooltipHtml =
514+
typeof dirTooltip === 'object' ? dirTooltip.html : dirTooltip}
515+
<span
516+
class="size-mismatch"
517+
use:tooltip={{
518+
html:
519+
'Content and on-disk sizes differ significantly.<br><br>' +
520+
dirTooltipHtml,
521+
}}
522+
>
523+
<CircleAlert size={12} color="var(--color-accent)" />
524+
</span>
525+
{/if}
497526
{:else if indexing}
498527
<span class="size-scanning">Scanning...</span>
499528
{:else}
@@ -613,6 +642,14 @@
613642
cursor: help;
614643
}
615644
645+
.size-mismatch {
646+
display: inline-flex;
647+
align-items: center;
648+
vertical-align: middle;
649+
margin-left: var(--spacing-xxs);
650+
cursor: help;
651+
}
652+
616653
.size-scanning {
617654
color: var(--color-text-tertiary);
618655
font-size: var(--font-size-xs);

apps/desktop/src/lib/file-explorer/views/dir-size-display.test.ts

Lines changed: 65 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
buildDirSizeTooltip,
1010
getDisplaySize,
1111
buildFileSizeTooltip,
12-
sizesDifferSignificantly,
12+
hasSizeMismatch,
1313
} from './full-list-utils'
1414

1515
// Mock settings store (required by full-list-utils)
@@ -138,19 +138,27 @@ describe('buildDirSizeTooltip', () => {
138138
expect(html).toContain('2.0 KB')
139139
})
140140

141-
it('shows both sizes on separate lines when physical differs significantly', () => {
141+
it('always shows both sizes when physical is available', () => {
142+
const result = buildDirSizeTooltip(1000000, 1000005, 10, 3, false, formatSize, formatNum, plural)
143+
const html = tooltipHtml(result)
144+
expect(html).toContain('Content:')
145+
expect(html).toContain('1000000 bytes')
146+
expect(html).toContain('On disk:')
147+
expect(html).toContain('1000005 bytes')
148+
})
149+
150+
it('shows both sizes when physical differs significantly', () => {
142151
const result = buildDirSizeTooltip(1000000, 800000, 10, 3, false, formatSize, formatNum, plural)
143152
const html = tooltipHtml(result)
144153
expect(html).toContain('Content:')
145154
expect(html).toContain('1000000 bytes')
146155
expect(html).toContain('On disk:')
147156
expect(html).toContain('800000 bytes')
148-
// Both size lines should be on separate lines from counts
149157
expect(html).toContain('<br>')
150158
})
151159

152-
it('shows single size when physical is similar', () => {
153-
const html = tooltipHtml(buildDirSizeTooltip(1000000, 1000005, 10, 3, false, formatSize, formatNum, plural))
160+
it('shows single size when physical is unavailable', () => {
161+
const html = tooltipHtml(buildDirSizeTooltip(1000000, undefined, 10, 3, false, formatSize, formatNum, plural))
154162
expect(html).toContain('1000000 bytes')
155163
expect(html).not.toContain('Content:')
156164
expect(html).not.toContain('On disk:')
@@ -200,36 +208,62 @@ describe('getDisplaySize', () => {
200208
})
201209

202210
// ============================================================================
203-
// sizesDifferSignificantly
211+
// hasSizeMismatch
204212
// ============================================================================
205213

206-
describe('sizesDifferSignificantly', () => {
207-
it('returns false when both are zero', () => {
208-
expect(sizesDifferSignificantly(0, 0)).toBe(false)
214+
describe('hasSizeMismatch', () => {
215+
it('returns false when logical is undefined', () => {
216+
expect(hasSizeMismatch(undefined, 500_000_000)).toBe(false)
217+
})
218+
219+
it('returns false when physical is undefined', () => {
220+
expect(hasSizeMismatch(500_000_000, undefined)).toBe(false)
221+
})
222+
223+
it('returns false when both are undefined', () => {
224+
expect(hasSizeMismatch(undefined, undefined)).toBe(false)
225+
})
226+
227+
it('returns false when logical is zero', () => {
228+
expect(hasSizeMismatch(0, 500_000_000)).toBe(false)
229+
})
230+
231+
it('returns false when physical is zero', () => {
232+
expect(hasSizeMismatch(500_000_000, 0)).toBe(false)
209233
})
210234

211-
it('returns true when one is zero and the other is not', () => {
212-
expect(sizesDifferSignificantly(0, 100)).toBe(true)
213-
expect(sizesDifferSignificantly(100, 0)).toBe(true)
235+
it('returns false when sizes are equal', () => {
236+
expect(hasSizeMismatch(1_000_000_000, 1_000_000_000)).toBe(false)
214237
})
215238

216-
it('returns false when values are equal', () => {
217-
expect(sizesDifferSignificantly(1000, 1000)).toBe(false)
239+
it('returns true when both thresholds are met (50% and 200 MB)', () => {
240+
// 600 MB logical, 300 MB physical → diff = 300 MB (>200 MB), 300/300 = 100% (>50%)
241+
expect(hasSizeMismatch(600_000_000, 300_000_000)).toBe(true)
242+
expect(hasSizeMismatch(300_000_000, 600_000_000)).toBe(true)
218243
})
219244

220-
it('returns false when difference is at the 1% boundary', () => {
221-
// 1% of 10000 = 100; difference of 100 → ratio = 0.01 → not >0.01
222-
expect(sizesDifferSignificantly(10000, 10100)).toBe(false)
245+
it('returns false when only percentage threshold is met but not absolute', () => {
246+
// 300 MB logical, 100 MB physical → diff = 200 MB, 200/100 = 200% (>50%)
247+
// BUT diff = 200 MB which is exactly 200_000_000, need >= so this is true
248+
// Use smaller values: 100 MB vs 50 MB → diff = 50 MB (<200 MB), 50/50 = 100% (>50%)
249+
expect(hasSizeMismatch(100_000_000, 50_000_000)).toBe(false)
223250
})
224251

225-
it('returns true when difference exceeds 1%', () => {
226-
// difference = 200, larger = 10200, ratio = 200/10200 ≈ 0.0196 > 0.01
227-
expect(sizesDifferSignificantly(10000, 10200)).toBe(true)
228-
expect(sizesDifferSignificantly(10200, 10000)).toBe(true)
252+
it('returns false when only absolute threshold is met but not percentage', () => {
253+
// 1 GB logical, 800 MB physical → diff = 200 MB (>=200 MB), but 200/800 = 25% (<50%)
254+
expect(hasSizeMismatch(1_000_000_000, 800_000_000)).toBe(false)
229255
})
230256

231-
it('returns true for large relative difference', () => {
232-
expect(sizesDifferSignificantly(100, 200)).toBe(true)
257+
it('returns true at exact boundary (200 MB diff, 50% relative)', () => {
258+
// 600 MB logical, 400 MB physical → diff = 200 MB (>=200 MB), 200/400 = 50% (>=50%)
259+
expect(hasSizeMismatch(600_000_000, 400_000_000)).toBe(true)
260+
})
261+
262+
it('returns false just under the absolute boundary', () => {
263+
// 599_999_999 logical, 399_999_999 physical → diff = 200_000_000, 200M/400M = 50%
264+
// Actually that's at boundary. Use: diff = 199_999_999
265+
// 599_999_999 logical, 400_000_000 physical → diff = 199_999_999 (<200 MB)
266+
expect(hasSizeMismatch(599_999_999, 400_000_000)).toBe(false)
233267
})
234268
})
235269

@@ -255,7 +289,7 @@ describe('buildFileSizeTooltip', () => {
255289
expect(html).toContain('2048 bytes')
256290
})
257291

258-
it('shows both sizes on separate lines when they differ significantly', () => {
292+
it('always shows both sizes when both are available', () => {
259293
const result = buildFileSizeTooltip(1000000, 800000, formatSize)
260294
const html = tooltipHtml(result)
261295
expect(html).toContain('Content:')
@@ -265,15 +299,17 @@ describe('buildFileSizeTooltip', () => {
265299
expect(html).toContain('<br>')
266300
})
267301

268-
it('shows single size when sizes are similar', () => {
302+
it('shows both sizes even when sizes are similar', () => {
269303
const html = tooltipHtml(buildFileSizeTooltip(1000000, 1000005, formatSize))
304+
expect(html).toContain('Content:')
270305
expect(html).toContain('1000000 bytes')
271-
expect(html).not.toContain('Content:')
306+
expect(html).toContain('On disk:')
307+
expect(html).toContain('1000005 bytes')
272308
})
273309

274-
it('shows single size when sizes are equal', () => {
310+
it('shows both sizes even when sizes are equal', () => {
275311
const html = tooltipHtml(buildFileSizeTooltip(500, 500, formatSize))
276-
expect(html).toContain('500 bytes')
277-
expect(html).not.toContain('Content:')
312+
expect(html).toContain('Content:')
313+
expect(html).toContain('On disk:')
278314
})
279315
})

apps/desktop/src/lib/file-explorer/views/full-list-utils.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,16 @@ export function getDisplaySize(
152152
return logical ?? physical
153153
}
154154

155-
/** Whether two sizes differ enough to show both (>1% difference). */
156-
export function sizesDifferSignificantly(a: number, b: number): boolean {
157-
if (a === 0 && b === 0) return false
158-
const larger = Math.max(a, b)
159-
return Math.abs(a - b) / larger > 0.01
155+
/**
156+
* Whether content and on-disk sizes differ enough to warrant a warning icon.
157+
* Both conditions must be true: ≥50% relative difference AND ≥200 MB absolute difference.
158+
*/
159+
export function hasSizeMismatch(logical: number | undefined, physical: number | undefined): boolean {
160+
if (logical === undefined || physical === undefined) return false
161+
if (logical === 0 || physical === 0) return false
162+
const diff = Math.abs(logical - physical)
163+
const smaller = Math.min(logical, physical)
164+
return diff >= smaller * 0.5 && diff >= 200_000_000
160165
}
161166

162167
/** Formats a byte count as colored HTML digit triads (same colors as the size column). */
@@ -172,16 +177,16 @@ function sizeLineHtml(label: string, bytes: number, formatSize: (b: number) => s
172177
}
173178

174179
/**
175-
* Build a rich HTML tooltip for a file showing both content and on-disk sizes when they differ.
176-
* Returns a plain string when only one size is available, or an `{ html }` object for rich display.
180+
* Build a rich HTML tooltip for a file showing both content and on-disk sizes.
181+
* Always shows both lines when both sizes are available. Falls back to a single line otherwise.
177182
*/
178183
export function buildFileSizeTooltip(
179184
logical: number | undefined,
180185
physical: number | undefined,
181186
formatSize: (bytes: number) => string,
182187
): string | { html: string } {
183188
if (logical === undefined && physical === undefined) return ''
184-
if (logical !== undefined && physical !== undefined && sizesDifferSignificantly(logical, physical)) {
189+
if (logical !== undefined && physical !== undefined) {
185190
return {
186191
html: `${sizeLineHtml('Content', logical, formatSize)}<br>${sizeLineHtml('On disk', physical, formatSize)}`,
187192
}
@@ -193,7 +198,7 @@ export function buildFileSizeTooltip(
193198

194199
/**
195200
* Build a rich HTML tooltip for the selection summary bar.
196-
* Shows "Selected" and "Of total" lines, with a separate "On disk" section when sizes differ.
201+
* Shows "Selected" and "Of total" lines, with a separate "On disk" section when physical sizes are available.
197202
*/
198203
export function buildSelectionSizeTooltip(
199204
selectedLogical: number,
@@ -207,10 +212,7 @@ export function buildSelectionSizeTooltip(
207212
const selLine = (label: string, bytes: number) => `${label}: ${formatSize(bytes)} (${formatBytesHtml(bytes)} bytes)`
208213
const lines: string[] = [selLine('Selected', selectedLogical), selLine('Of total', totalLogical)]
209214

210-
const showDisk =
211-
sizesDifferSignificantly(selectedLogical, selectedPhysical) ||
212-
sizesDifferSignificantly(totalLogical, totalPhysical)
213-
if (showDisk) {
215+
if (totalPhysical > 0) {
214216
lines.push('', 'On disk:', selLine('Selected', selectedPhysical), selLine('Of total', totalPhysical))
215217
}
216218

@@ -268,7 +270,7 @@ export function buildDirSizeTooltip(
268270
const lines: string[] = []
269271

270272
// Size lines with colored byte triads
271-
if (recursivePhysicalSize !== undefined && sizesDifferSignificantly(recursiveSize, recursivePhysicalSize)) {
273+
if (recursivePhysicalSize !== undefined) {
272274
lines.push(sizeLineHtml('Content', recursiveSize, formatSize))
273275
lines.push(sizeLineHtml('On disk', recursivePhysicalSize, formatSize))
274276
} else {

apps/desktop/src/lib/settings/reactive-settings.svelte.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ let useAppIconsForDocuments = $state<boolean>(true)
3030
let directorySortMode = $state<DirectorySortMode>('likeFiles')
3131
let appColor = $state<AppColor>('cmdr-gold')
3232
let sizeDisplay = $state<SizeDisplayMode>('smart')
33+
let sizeMismatchWarning = $state<boolean>(true)
3334

3435
let initialized = false
3536
let unsubscribe: (() => void) | undefined
@@ -54,6 +55,7 @@ export async function initReactiveSettings(): Promise<void> {
5455
directorySortMode = getSetting('listing.directorySortMode')
5556
appColor = getSetting('appearance.appColor')
5657
sizeDisplay = getSetting('listing.sizeDisplay')
58+
sizeMismatchWarning = getSetting('listing.sizeMismatchWarning')
5759

5860
// Subscribe to changes (including cross-window changes)
5961
unsubscribe = onSettingChange((id, value) => {
@@ -92,6 +94,9 @@ export async function initReactiveSettings(): Promise<void> {
9294
case 'listing.sizeDisplay':
9395
sizeDisplay = value as SizeDisplayMode
9496
break
97+
case 'listing.sizeMismatchWarning':
98+
sizeMismatchWarning = value as boolean
99+
break
95100
}
96101
})
97102

@@ -145,6 +150,11 @@ export function getSizeDisplayMode(): SizeDisplayMode {
145150
return sizeDisplay
146151
}
147152

153+
/** Get whether the size mismatch warning icon is enabled */
154+
export function getSizeMismatchWarning(): boolean {
155+
return sizeMismatchWarning
156+
}
157+
148158
// ============================================================================
149159
// Formatting utilities that use reactive settings
150160
// ============================================================================

0 commit comments

Comments
 (0)