Skip to content

Commit f2019af

Browse files
committed
Bugfix: F8 dies after volume switch (Group A null-vs-undefined sweep)
After commit dc5f0b4 dropped `skip_serializing_if = "Option::is_none"` from `FileEntry` (and friends), absent fields started serializing as explicit JSON `null` instead of being omitted. Code that did `if (entry.size !== undefined)` then proceeded with `null` and crashed downstream — `formatDateTimeParts(null)`, `formatBytesHtml(null)`, etc. The crash inside a Svelte 5 `$effect` corrupted the reactive graph for sibling effects on the same component, which is why a state write to `showDeleteDialog = true` after a volume switch was silently dropped and the Delete dialog never mounted. Bisected to dc5f0b4 + agent-driven sweep of every `=== undefined` / `!== undefined` site that touches IPC-derived optional fields. Eleven offenders across six files: - `views/measure-column-widths.ts` — `entry.modifiedAt !== undefined` guarding `formatDateTimeParts(...)`. Top suspect: this `$effect` runs on every listing refresh, so the volume-switch path was the killer. - `views/full-list-utils.ts` — `hasSizeMismatch` and `buildFileSizeTooltip` accepted `number | undefined`; widened to also accept `null`, switched all guards. - `selection/selection-info-utils.ts` — `formatDate`, `buildDateTooltip`, `isPermissionDenied`. - `rename/RenameConflictDialog.svelte` + `rename/rename-operations.ts` — `renamedIsNewer`/`existingFile` derived comparisons. - `file-operations/delete/DeleteDialog.svelte` + `delete/delete-dialog-utils.ts` — `formatItemSize` and the `DeleteSourceItem` type. - `file-explorer/pane/dialog-state.svelte.ts` — type-guard filter on per-item sizes for trash progress. Each non-trivial fix carries a "Group A wire-format" inline comment so future devs know why we use `!= null` instead of `!== undefined` here. Tests: svelte-check / eslint / 1739 unit tests all green.
1 parent 73c1e74 commit f2019af

8 files changed

Lines changed: 42 additions & 29 deletions

File tree

apps/desktop/src/lib/file-explorer/pane/dialog-state.svelte.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,10 +308,11 @@ export function createDialogState(deps: DialogStateDeps) {
308308

309309
const opType: TransferOperationType = isPermanent ? 'delete' : 'trash'
310310

311-
// Collect per-item sizes for trash progress if available
311+
// Collect per-item sizes for trash progress if available.
312+
// Group A wire-format: IPC sends `null` for absent sizes, so reject both null and undefined.
312313
const sizes = deleteDialogProps.sourceItems
313314
.map((item) => (item.isDirectory ? item.recursiveSize : item.size))
314-
.filter((s): s is number => s !== undefined)
315+
.filter((s): s is number => s != null)
315316
const itemSizes = sizes.length === deleteDialogProps.sourceItems.length ? sizes : undefined
316317

317318
transferProgressProps = {

apps/desktop/src/lib/file-explorer/rename/RenameConflictDialog.svelte

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
1515
const { renamedFile, existingFile, onResolve }: Props = $props()
1616
17+
// Group A wire-format: IPC may send `null` for modifiedAt; accept both null and undefined.
1718
const renamedIsNewer = $derived(
18-
renamedFile.modifiedAt !== undefined &&
19-
existingFile.modifiedAt !== undefined &&
19+
renamedFile.modifiedAt != null &&
20+
existingFile.modifiedAt != null &&
2021
renamedFile.modifiedAt > existingFile.modifiedAt,
2122
)
2223
const renamedIsLarger = $derived(renamedFile.size > existingFile.size)
@@ -76,7 +77,7 @@
7677
class="meta-value"
7778
class:newer={!renamedIsNewer &&
7879
renamedFile.modifiedAt !== existingFile.modifiedAt &&
79-
existingFile.modifiedAt !== undefined}>{formatDateTime(existingFile.modifiedAt)}</span
80+
existingFile.modifiedAt != null}>{formatDateTime(existingFile.modifiedAt)}</span
8081
>
8182
</div>
8283
</div>

apps/desktop/src/lib/file-explorer/rename/rename-operations.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import { extensionsDifferMeaningfully, getExtension } from '$lib/utils/filename-
99
export interface ConflictFileInfo {
1010
name: string
1111
size: number
12-
/** Unix timestamp in seconds, or undefined if unavailable */
13-
modifiedAt: number | undefined
12+
/** Unix timestamp in seconds, or null/undefined if unavailable. Group A wire-format: IPC sends `null`. */
13+
modifiedAt: number | null | undefined
1414
}
1515

1616
export type RenameConflictResolution = 'overwrite-trash' | 'overwrite-delete' | 'cancel' | 'continue'

apps/desktop/src/lib/file-explorer/selection/selection-info-utils.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ export function formatSizeForDisplay(
7474
}
7575

7676
/** Formats timestamp as YYYY-MM-DD hh:mm:ss */
77-
export function formatDate(timestamp: number | undefined): string {
78-
if (timestamp === undefined) return ''
77+
export function formatDate(timestamp: number | null | undefined): string {
78+
if (timestamp == null) return ''
7979
const date = new Date(timestamp * 1000)
8080
const pad = (n: number) => String(n).padStart(2, '0')
8181
const year = date.getFullYear()
@@ -90,10 +90,11 @@ export function formatDate(timestamp: number | undefined): string {
9090
/** Builds date tooltip content */
9191
export function buildDateTooltip(e: FileEntry): string {
9292
const lines: string[] = []
93-
if (e.createdAt !== undefined) lines.push(`Created: ${formatDate(e.createdAt)}`)
94-
if (e.openedAt !== undefined) lines.push(`Last opened: ${formatDate(e.openedAt)}`)
95-
if (e.addedAt !== undefined) lines.push(`Last moved ("added"): ${formatDate(e.addedAt)}`)
96-
if (e.modifiedAt !== undefined) lines.push(`Last modified: ${formatDate(e.modifiedAt)}`)
93+
// `!= null` because IPC payloads serialize `Option::None` as JSON `null`.
94+
if (e.createdAt != null) lines.push(`Created: ${formatDate(e.createdAt)}`)
95+
if (e.openedAt != null) lines.push(`Last opened: ${formatDate(e.openedAt)}`)
96+
if (e.addedAt != null) lines.push(`Last moved ("added"): ${formatDate(e.addedAt)}`)
97+
if (e.modifiedAt != null) lines.push(`Last modified: ${formatDate(e.modifiedAt)}`)
9798
return lines.join('\n')
9899
}
99100

@@ -137,7 +138,7 @@ export function isBrokenSymlink(entry: FileEntry | null): boolean {
137138

138139
/** Checks if entry has permission denied */
139140
export function isPermissionDenied(entry: FileEntry | null): boolean {
140-
return entry !== null && !entry.isSymlink && entry.permissions === 0 && entry.size === undefined
141+
return entry !== null && !entry.isSymlink && entry.permissions === 0 && entry.size == null
141142
}
142143

143144
// ============================================================================

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

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,10 @@ export function getDisplaySize(
233233
* Whether content and on-disk sizes differ enough to warrant a warning icon.
234234
* Both conditions must be true: ≥50% relative difference AND ≥200 MB absolute difference.
235235
*/
236-
export function hasSizeMismatch(logical: number | undefined, physical: number | undefined): boolean {
237-
if (logical === undefined || physical === undefined) return false
236+
// Group A wire-format: callers pass IPC-derived `number | null` values directly.
237+
// Use `== null` so both `null` and `undefined` count as "no value".
238+
export function hasSizeMismatch(logical: number | null | undefined, physical: number | null | undefined): boolean {
239+
if (logical == null || physical == null) return false
238240
if (logical === 0 || physical === 0) return false
239241
const diff = Math.abs(logical - physical)
240242
const smaller = Math.min(logical, physical)
@@ -258,18 +260,19 @@ function sizeLineHtml(label: string, bytes: number, formatSize: (b: number) => s
258260
* Always shows both lines when both sizes are available. Falls back to a single line otherwise.
259261
*/
260262
export function buildFileSizeTooltip(
261-
logical: number | undefined,
262-
physical: number | undefined,
263+
logical: number | null | undefined,
264+
physical: number | null | undefined,
263265
formatSize: (bytes: number) => string,
264266
): string | { html: string } {
265-
if (logical === undefined && physical === undefined) return ''
266-
if (logical !== undefined && physical !== undefined) {
267+
// Group A wire-format: IPC sends `null`, not `undefined`. Use `!= null` to handle both.
268+
if (logical == null && physical == null) return ''
269+
if (logical != null && physical != null) {
267270
return {
268271
html: `${sizeLineHtml('Content', logical, formatSize)}<br>${sizeLineHtml('On disk', physical, formatSize)}`,
269272
}
270273
}
271274
const size = logical ?? physical
272-
if (size === undefined) return ''
275+
if (size == null) return ''
273276
return { html: `${formatSize(size)} (${formatBytesHtml(size)} bytes)` }
274277
}
275278

apps/desktop/src/lib/file-explorer/views/measure-column-widths.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -286,7 +286,11 @@ export function computeFullListColumnWidths(args: {
286286
if (rowSize > sizeMax) sizeMax = rowSize
287287
if (iconSuffix > sizeIconSuffixMax) sizeIconSuffixMax = iconSuffix
288288

289-
if (entry.modifiedAt !== undefined) {
289+
// `!= null` (not `!== undefined`): IPC payloads serialize `Option::None` as
290+
// explicit `null`, and `formatDateTimeParts(null)` throws inside this `$effect`,
291+
// which corrupts Svelte's reactive graph for sibling effects on the same
292+
// component (this was the F8-after-volume-switch killer).
293+
if (entry.modifiedAt != null) {
290294
date = foldDate(date, formatDateTimeParts(entry.modifiedAt), measure)
291295
}
292296
}

apps/desktop/src/lib/file-operations/delete/DeleteDialog.svelte

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,17 +179,18 @@
179179
* Always uses logical (content) sizes — not worth plumbing the display mode setting
180180
* through the delete dialog infrastructure for a transient confirmation dialog. */
181181
function formatItemSize(item: DeleteSourceItem): string {
182+
// Group A wire-format: IPC sends `null` for absent fields, not `undefined`.
182183
if (item.isDirectory) {
183184
const size = item.recursiveSize
184185
const fileCount = item.recursiveFileCount
185186
const parts: string[] = []
186-
if (size !== undefined) parts.push(formatFileSize(size))
187-
if (fileCount !== undefined) {
187+
if (size != null) parts.push(formatFileSize(size))
188+
if (fileCount != null) {
188189
parts.push(`${formatNumber(fileCount)} ${fileCount === 1 ? 'file' : 'files'}`)
189190
}
190191
return parts.length > 0 ? parts.join(' ') : ''
191192
}
192-
return item.size !== undefined ? formatFileSize(item.size) : ''
193+
return item.size != null ? formatFileSize(item.size) : ''
193194
}
194195
</script>
195196

apps/desktop/src/lib/file-operations/delete/delete-dialog-utils.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33
* Pure functions — no Svelte reactivity, no side effects.
44
*/
55

6-
/** Minimal item info needed by DeleteDialog for display. */
6+
/** Minimal item info needed by DeleteDialog for display.
7+
* Group A wire-format: IPC sends `null` for absent FileEntry fields, not `undefined`.
8+
* Accept both so the constructed item shape matches whatever the caller passes through. */
79
export interface DeleteSourceItem {
810
name: string
9-
size?: number
11+
size?: number | null
1012
isDirectory: boolean
1113
isSymlink: boolean
12-
recursiveSize?: number
13-
recursiveFileCount?: number
14+
recursiveSize?: number | null
15+
recursiveFileCount?: number | null
1416
}
1517

1618
/**

0 commit comments

Comments
 (0)