Skip to content

Commit 3928c1c

Browse files
committed
Selection: Include directory sizes
- `ListingStats` now sums directory `recursive_size` alongside file sizes - Renamed `total_file_size`/`selected_file_size` → `total_size`/`selected_size` across Rust, TypeScript, and Svelte - Added `refresh_listing_index_sizes` IPC command to re-enrich backend cache when index updates arrive, so `get_listing_stats` stays on a read lock - Dirs-only selection shows size triads when sizes are available, falls back to count-only when indexing is off - Removed file/dir count percentages from selection summary - Frontend `refreshIndexSizes` now chains: re-enrich backend cache → fetch fresh stats
1 parent b516f86 commit 3928c1c

13 files changed

Lines changed: 79 additions & 33 deletions

File tree

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ use crate::file_system::{
1919
list_active_operations as ops_list_active_operations, list_directory_end as ops_list_directory_end,
2020
list_directory_start_streaming as ops_list_directory_start_streaming,
2121
list_directory_start_with_volume as ops_list_directory_start_with_volume, move_files_start as ops_move_files_start,
22-
resort_listing as ops_resort_listing, scan_for_volume_copy as ops_scan_for_volume_copy,
23-
trash_files_start as ops_trash_files_start,
22+
refresh_listing_index_sizes as ops_refresh_listing_index_sizes, resort_listing as ops_resort_listing,
23+
scan_for_volume_copy as ops_scan_for_volume_copy, trash_files_start as ops_trash_files_start,
2424
};
2525
use std::path::{Path, PathBuf};
2626
#[cfg(target_os = "macos")]
@@ -287,6 +287,12 @@ pub fn get_listing_stats(
287287
ops_get_listing_stats(&listing_id, include_hidden, selected_indices.as_deref())
288288
}
289289

290+
/// Re-enriches cached listing entries with fresh drive index data.
291+
#[tauri::command]
292+
pub fn refresh_listing_index_sizes(listing_id: String) -> Result<(), String> {
293+
ops_refresh_listing_index_sizes(&listing_id)
294+
}
295+
290296
// ============================================================================
291297
// Benchmarking support
292298
// ============================================================================

apps/desktop/src-tauri/src/file_system/listing/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Frontend Backend
7575
**Why**: Frontend expects sorted order. Sorting 50k entries takes ~15ms (fast enough). Done in background task after all entries collected.
7676

7777
**Decision**: Enrichment at cache-write time, not on `get_file_range`
78-
**Why**: All paths that store entries in `LISTING_CACHE` (streaming, watcher update, re-sort) enrich before storing. Index freshness is handled event-driven: `index-dir-updated``refreshIndexSizes``getDirStatsBatch` (a separate IPC path that bypasses `get_file_range`). Re-enriching on every page fetch was redundant.
78+
**Why**: All paths that store entries in `LISTING_CACHE` (streaming, watcher update, re-sort) enrich before storing. Index freshness is handled event-driven: `index-dir-updated``refreshIndexSizes``refresh_listing_index_sizes` (dedicated IPC command that write-locks the cache and re-enriches entries). This keeps `get_listing_stats` as a read-only operation while ensuring it sees up-to-date `recursive_size` values. The frontend calls `refreshListingIndexSizes` before `fetchListingStats` so the cache is fresh when stats are computed.
7979

8080
**Decision**: Hidden files filtering in Rust, not frontend
8181
**Why**: Cannot know visible count until all files read. APIs accept `include_hidden: bool`, filter during `get_file_range()` iteration.

apps/desktop/src-tauri/src/file_system/listing/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ pub(crate) mod streaming;
1212
pub use metadata::{ExtendedMetadata, FileEntry};
1313
pub use operations::{
1414
ListingStartResult, ListingStats, ResortResult, find_file_index, get_file_at, get_file_range, get_listing_stats,
15-
get_max_filename_width, get_total_count, list_directory_end, list_directory_start_with_volume, resort_listing,
15+
get_max_filename_width, get_total_count, list_directory_end, list_directory_start_with_volume,
16+
refresh_listing_index_sizes, resort_listing,
1617
};
1718
pub use reading::{get_single_entry, list_directory_core, list_directory_core_with_progress};
1819
pub use sorting::{DirectorySortMode, SortColumn, SortOrder};

apps/desktop/src-tauri/src/file_system/listing/operations.rs

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -489,14 +489,14 @@ pub struct ListingStats {
489489
/// Not including directories.
490490
pub total_files: usize,
491491
pub total_dirs: usize,
492-
/// In bytes.
493-
pub total_file_size: u64,
492+
/// Total size in bytes (files + directory recursive sizes).
493+
pub total_size: u64,
494494
/// Present only if `selected_indices` was provided.
495495
pub selected_files: Option<usize>,
496496
/// Present only if `selected_indices` was provided.
497497
pub selected_dirs: Option<usize>,
498-
/// In bytes. Present only if `selected_indices` was provided.
499-
pub selected_file_size: Option<u64>,
498+
/// Total size of selected entries in bytes (files + directory recursive sizes). Present only if `selected_indices` was provided.
499+
pub selected_size: Option<u64>,
500500
}
501501

502502
/// Gets statistics about a cached listing.
@@ -524,21 +524,24 @@ pub fn get_listing_stats(
524524
// Calculate totals
525525
let mut total_files: usize = 0;
526526
let mut total_dirs: usize = 0;
527-
let mut total_file_size: u64 = 0;
527+
let mut total_size: u64 = 0;
528528

529529
for entry in &visible_entries {
530530
if entry.is_directory {
531531
total_dirs += 1;
532+
if let Some(size) = entry.recursive_size {
533+
total_size += size;
534+
}
532535
} else {
533536
total_files += 1;
534537
if let Some(size) = entry.size {
535-
total_file_size += size;
538+
total_size += size;
536539
}
537540
}
538541
}
539542

540543
// Calculate selection stats if indices provided
541-
let (selected_files, selected_dirs, selected_file_size) = if let Some(indices) = selected_indices {
544+
let (selected_files, selected_dirs, selected_size) = if let Some(indices) = selected_indices {
542545
let mut sel_files: usize = 0;
543546
let mut sel_dirs: usize = 0;
544547
let mut sel_size: u64 = 0;
@@ -547,6 +550,9 @@ pub fn get_listing_stats(
547550
if let Some(entry) = visible_entries.get(idx) {
548551
if entry.is_directory {
549552
sel_dirs += 1;
553+
if let Some(size) = entry.recursive_size {
554+
sel_size += size;
555+
}
550556
} else {
551557
sel_files += 1;
552558
if let Some(size) = entry.size {
@@ -564,9 +570,21 @@ pub fn get_listing_stats(
564570
Ok(ListingStats {
565571
total_files,
566572
total_dirs,
567-
total_file_size,
573+
total_size,
568574
selected_files,
569575
selected_dirs,
570-
selected_file_size,
576+
selected_size,
571577
})
572578
}
579+
580+
/// Re-enriches directory entries in a cached listing with fresh index data.
581+
///
582+
/// Called when `index-dir-updated` fires so that subsequent `get_listing_stats`
583+
/// reads see up-to-date `recursive_size` values without needing a write lock.
584+
pub fn refresh_listing_index_sizes(listing_id: &str) -> Result<(), String> {
585+
let mut cache = LISTING_CACHE.write().map_err(|_| "Failed to acquire cache lock")?;
586+
if let Some(listing) = cache.get_mut(listing_id) {
587+
crate::indexing::enrich_entries_with_index(&mut listing.entries);
588+
}
589+
Ok(())
590+
}

apps/desktop/src-tauri/src/file_system/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ pub use listing::{
2727
DirectorySortMode, FileEntry, ListingStartResult, ListingStats, ResortResult, SortColumn, SortOrder,
2828
StreamingListingStartResult, cancel_listing, find_file_index, get_file_at, get_file_range, get_listing_stats,
2929
get_max_filename_width, get_total_count, list_directory_end, list_directory_start_streaming,
30-
list_directory_start_with_volume, resort_listing,
30+
list_directory_start_with_volume, refresh_listing_index_sizes, resort_listing,
3131
};
3232
// macOS-only exports (used by drag operations)
3333
#[cfg(target_os = "macos")]

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,7 @@ pub fn run() {
600600
commands::file_system::scan_volume_for_copy,
601601
commands::file_system::scan_volume_for_conflicts,
602602
commands::file_system::get_listing_stats,
603+
commands::file_system::refresh_listing_index_sizes,
603604
commands::file_system::start_selection_drag,
604605
commands::file_system::prepare_self_drag_overlay,
605606
commands::file_system::clear_self_drag_overlay,

apps/desktop/src/lib/file-explorer/pane/FilePane.svelte

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
listen,
3131
onMtpDeviceRemoved,
3232
openFile,
33+
refreshListingIndexSizes,
3334
showFileContextMenu,
3435
type UnlistenFn,
3536
updateMenuContext,
@@ -389,6 +390,10 @@
389390
const listRef = viewMode === 'brief' ? briefListRef : fullListRef
390391
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
391392
listRef?.refreshIndexSizes?.()
393+
// Re-enrich backend cache entries so fetchListingStats sees fresh recursive_size values
394+
if (listingId) {
395+
void refreshListingIndexSizes(listingId).then(() => fetchListingStats())
396+
}
392397
}
393398
394399
export function getSwapState(): SwapState {

apps/desktop/src/lib/file-explorer/pane/transfer-operations.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ describe('buildTransferPropsFromSelection', () => {
129129
vi.mocked(getListingStats).mockResolvedValueOnce({
130130
totalFiles: 2,
131131
totalDirs: 1,
132-
totalFileSize: 1000,
132+
totalSize: 1000,
133133
selectedFiles: 2,
134134
selectedDirs: 1,
135135
})
@@ -161,7 +161,7 @@ describe('buildTransferPropsFromSelection', () => {
161161
vi.mocked(getListingStats).mockResolvedValueOnce({
162162
totalFiles: 1,
163163
totalDirs: 0,
164-
totalFileSize: 500,
164+
totalSize: 500,
165165
selectedFiles: 1,
166166
selectedDirs: 0,
167167
})

apps/desktop/src/lib/file-explorer/selection/CLAUDE.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,12 @@ Status bar rendered below each pane. Four display modes via `$derived displayMod
4343
| `no-selection` | Full mode, no selection — shows total file/dir counts |
4444
| `file-info` | Brief mode, no selection — shows name, size triads, date |
4545

46-
Stale indicator (`⚠️`) appears in `selection-summary` when `isScanning()` is true and directories are selected (dir
47-
sizes may be incomplete).
46+
In `selection-summary` mode, directory recursive sizes are included in the size display when available (from the drive
47+
index). The `hasOnlyDirs` branch shows size triads when `totalSize > 0`; when sizes are unavailable (indexing off), it
48+
falls back to showing only dir count and percentage.
49+
50+
Stale indicator (`⚠️`) appears in `selection-summary` when `isScanning()` is true and directories are selected, because
51+
dir sizes may be incomplete during scanning.
4852

4953
Filename truncation in `file-info` mode uses a ResizeObserver + throwaway `<span>` measurement for middle truncation
5054
(preserves file extension). The truncation runs binary search via `getTruncatedName`, triggered reactively by

apps/desktop/src/lib/file-explorer/selection/SelectionInfo.svelte

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,10 @@
197197
// Computed values for selection summary
198198
const selectedFiles = $derived(stats?.selectedFiles ?? 0)
199199
const selectedDirs = $derived(stats?.selectedDirs ?? 0)
200-
const selectedFileSize = $derived(stats?.selectedFileSize ?? 0)
200+
const selectedSize = $derived(stats?.selectedSize ?? 0)
201201
const totalFiles = $derived(stats?.totalFiles ?? 0)
202202
const totalDirs = $derived(stats?.totalDirs ?? 0)
203-
const totalFileSize = $derived(stats?.totalFileSize ?? 0)
203+
const totalSize = $derived(stats?.totalSize ?? 0)
204204
205205
const hasFiles = $derived(totalFiles > 0)
206206
const hasDirs = $derived(totalDirs > 0)
@@ -209,17 +209,15 @@
209209
// When directories are selected during scanning, sizes might be incomplete
210210
const showSelectionStale = $derived(scanning && selectedDirs > 0)
211211
212-
const sizePercentage = $derived(calculatePercentage(selectedFileSize, totalFileSize))
213-
const filePercentage = $derived(calculatePercentage(selectedFiles, totalFiles))
214-
const dirPercentage = $derived(calculatePercentage(selectedDirs, totalDirs))
212+
const sizePercentage = $derived(calculatePercentage(selectedSize, totalSize))
215213
216214
// Size triads for selection summary
217-
const selectedSizeTriads = $derived(formatSizeTriads(selectedFileSize))
218-
const totalSizeTriads = $derived(formatSizeTriads(totalFileSize))
215+
const selectedSizeTriads = $derived(formatSizeTriads(selectedSize))
216+
const totalSizeTriads = $derived(formatSizeTriads(totalSize))
219217
220218
// Tooltip with human-readable sizes
221219
const selectionSizeTooltip = $derived(
222-
hasFiles ? `${formatFileSize(selectedFileSize)} of ${formatFileSize(totalFileSize)}` : undefined,
220+
totalSize > 0 ? `${formatFileSize(selectedSize)} of ${formatFileSize(totalSize)}` : undefined,
223221
)
224222
</script>
225223

@@ -256,8 +254,15 @@
256254
<span class="summary-text" use:tooltip={selectionSizeTooltip}>
257255
{#if hasOnlyDirs}
258256
<!-- Only dirs, no files -->
257+
{#if totalSize > 0}
258+
{#each selectedSizeTriads as triad, i (i)}<span class={triad.tierClass}>{triad.value}</span>{/each}
259+
of
260+
{#each totalSizeTriads as triad, i (i)}<span class={triad.tierClass}>{triad.value}</span>{/each}
261+
({sizePercentage}%) selected in
262+
{/if}
259263
{formatNumber(selectedDirs)} of {formatNumber(totalDirs)}
260-
{pluralize(totalDirs, 'dir', 'dirs')} ({dirPercentage}%) selected.
264+
{pluralize(totalDirs, 'dir', 'dirs')}{#if totalSize === 0}
265+
selected{/if}.
261266
{#if showSelectionStale}
262267
<span class="stale-indicator" use:tooltip={'Might be outdated. Currently scanning...'}>⚠️</span>
263268
{/if}
@@ -267,9 +272,9 @@
267272
of
268273
{#each totalSizeTriads as triad, i (i)}<span class={triad.tierClass}>{triad.value}</span>{/each}
269274
({sizePercentage}%) selected in {formatNumber(selectedFiles)} of {formatNumber(totalFiles)}
270-
{pluralize(totalFiles, 'file', 'files')} ({filePercentage}%){#if hasDirs}
275+
{pluralize(totalFiles, 'file', 'files')}{#if hasDirs}
271276
&nbsp;and {formatNumber(selectedDirs)} of {formatNumber(totalDirs)}
272-
{pluralize(totalDirs, 'dir', 'dirs')} ({dirPercentage}%){/if}.
277+
{pluralize(totalDirs, 'dir', 'dirs')}{/if}.
273278
{#if showSelectionStale}
274279
<span class="stale-indicator" use:tooltip={'Might be outdated. Currently scanning...'}>⚠️</span>
275280
{/if}

0 commit comments

Comments
 (0)