Skip to content

Commit fb5f027

Browse files
committed
Wire up copy function!
1 parent 281f45e commit fb5f027

9 files changed

Lines changed: 883 additions & 26 deletions

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"updater.svelte.ts": { "reason": "Depends on Tauri updater APIs" },
3333
"window-state.ts": { "reason": "Depends on Tauri window APIs" },
3434
"write-operations/CopyDialog.svelte": { "reason": "UI modal, logic tested in copy-dialog-utils.test.ts" },
35-
"write-operations/DirectionIndicator.svelte": { "reason": "Simple UI component, logic tested in copy-dialog-utils.test.ts" }
35+
"write-operations/DirectionIndicator.svelte": { "reason": "Simple UI component, logic tested in copy-dialog-utils.test.ts" },
36+
"write-operations/CopyProgressDialog.svelte": { "reason": "UI dialog, depends on Tauri events" }
3637
}
3738
}

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -752,10 +752,26 @@ pub fn get_file_at(listing_id: &str, index: usize, include_hidden: bool) -> Resu
752752
.ok_or_else(|| format!("Listing not found: {}", listing_id))?;
753753

754754
if include_hidden {
755-
Ok(listing.entries.get(index).cloned())
755+
let result = listing.entries.get(index).cloned();
756+
if result.is_none() {
757+
log::error!(
758+
"get_file_at: index {} out of bounds (listing has {} entries) - frontend/backend index mismatch!",
759+
index,
760+
listing.entries.len()
761+
);
762+
}
763+
Ok(result)
756764
} else {
757765
let visible: Vec<&FileEntry> = listing.entries.iter().filter(|e| !e.name.starts_with('.')).collect();
758-
Ok(visible.get(index).cloned().cloned())
766+
let result = visible.get(index).cloned().cloned();
767+
if result.is_none() {
768+
log::error!(
769+
"get_file_at: index {} out of bounds (listing has {} visible entries) - frontend/backend index mismatch!",
770+
index,
771+
visible.len()
772+
);
773+
}
774+
Ok(result)
759775
}
760776
}
761777

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

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,13 @@ pub async fn copy_files_start(
589589
validate_destination_not_inside_source(&sources, &destination)?;
590590

591591
let operation_id = Uuid::new_v4().to_string();
592+
log::info!(
593+
"copy_files_start: operation_id={}, sources={:?}, destination={:?}, dry_run={}",
594+
operation_id,
595+
sources,
596+
destination,
597+
config.dry_run
598+
);
592599
let state = Arc::new(WriteOperationState {
593600
cancelled: AtomicBool::new(false),
594601
progress_interval: Duration::from_millis(config.progress_interval_ms),
@@ -1048,6 +1055,12 @@ fn scan_sources(
10481055
}
10491056

10501057
// Emit final scanning progress
1058+
log::debug!(
1059+
"scan: emitting final write-progress op={} phase=scanning files={} bytes={}",
1060+
operation_id,
1061+
files.len(),
1062+
total_bytes
1063+
);
10511064
let _ = app.emit(
10521065
"write-progress",
10531066
WriteProgressEvent {
@@ -1151,6 +1164,12 @@ fn scan_path_recursive(
11511164
// Emit progress periodically
11521165
if last_progress_time.elapsed() >= *progress_interval {
11531166
let current_file = path.file_name().map(|n| n.to_string_lossy().to_string());
1167+
log::debug!(
1168+
"scan: emitting write-progress op={} phase=scanning files_found={} bytes_found={}",
1169+
operation_id,
1170+
files.len(),
1171+
*total_bytes
1172+
);
11541173
let _ = app.emit(
11551174
"write-progress",
11561175
WriteProgressEvent {
@@ -1729,6 +1748,12 @@ fn copy_files_with_progress(
17291748
) -> Result<(), WriteOperationError> {
17301749
use tauri::Emitter;
17311750

1751+
log::debug!(
1752+
"copy_files_with_progress: starting operation_id={}, {} sources",
1753+
operation_id,
1754+
sources.len()
1755+
);
1756+
17321757
// Handle dry-run mode
17331758
if config.dry_run {
17341759
let scan_result = dry_run_scan(
@@ -1759,7 +1784,14 @@ fn copy_files_with_progress(
17591784
}
17601785

17611786
// Phase 1: Scan
1787+
log::debug!("copy_files_with_progress: starting scan phase for operation_id={}", operation_id);
17621788
let scan_result = scan_sources(sources, state, app, operation_id, WriteOperationType::Copy)?;
1789+
log::info!(
1790+
"copy_files_with_progress: scan complete for operation_id={}, files={}, bytes={}",
1791+
operation_id,
1792+
scan_result.file_count,
1793+
scan_result.total_bytes
1794+
);
17631795

17641796
// Phase 2: Copy files with rollback support
17651797
let mut transaction = CopyTransaction::new();
@@ -1798,6 +1830,13 @@ fn copy_files_with_progress(
17981830
// Spawn async sync for durability (non-blocking)
17991831
spawn_async_sync();
18001832

1833+
log::info!(
1834+
"copy_files_with_progress: completed op={} files={} bytes={}",
1835+
operation_id,
1836+
files_done,
1837+
bytes_done
1838+
);
1839+
18011840
// Emit completion
18021841
let _ = app.emit(
18031842
"write-complete",
@@ -1812,17 +1851,24 @@ fn copy_files_with_progress(
18121851
}
18131852
Err(e) => {
18141853
// Failure - rollback created files
1854+
log::error!(
1855+
"copy_files_with_progress: failed op={} error={:?}, rolling back",
1856+
operation_id,
1857+
e
1858+
);
18151859
transaction.rollback();
18161860

1817-
// Emit error
1818-
let _ = app.emit(
1819-
"write-error",
1820-
WriteErrorEvent {
1821-
operation_id: operation_id.to_string(),
1822-
operation_type: WriteOperationType::Copy,
1823-
error: e.clone(),
1824-
},
1825-
);
1861+
// Don't emit write-error for cancellation - write-cancelled was already emitted
1862+
if !matches!(e, WriteOperationError::Cancelled { .. }) {
1863+
let _ = app.emit(
1864+
"write-error",
1865+
WriteErrorEvent {
1866+
operation_id: operation_id.to_string(),
1867+
operation_type: WriteOperationType::Copy,
1868+
error: e.clone(),
1869+
},
1870+
);
1871+
}
18261872
Err(e)
18271873
}
18281874
}
@@ -1852,6 +1898,11 @@ fn copy_path_recursive(
18521898

18531899
// Check cancellation
18541900
if state.cancelled.load(Ordering::Relaxed) {
1901+
log::info!(
1902+
"copy: cancelled by user op={} files_processed={}",
1903+
operation_id,
1904+
*files_done
1905+
);
18551906
let _ = app.emit(
18561907
"write-cancelled",
18571908
WriteCancelledEvent {
@@ -2003,6 +2054,14 @@ fn copy_path_recursive(
20032054
// Emit progress
20042055
if last_progress_time.elapsed() >= *progress_interval {
20052056
let current_file_name = file_name.to_string_lossy().to_string();
2057+
log::debug!(
2058+
"copy: emitting write-progress op={} phase=copying files={}/{} bytes={}/{}",
2059+
operation_id,
2060+
*files_done,
2061+
files_total,
2062+
*bytes_done,
2063+
bytes_total
2064+
);
20062065
let _ = app.emit(
20072066
"write-progress",
20082067
WriteProgressEvent {

apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte

Lines changed: 88 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import PaneResizer from './PaneResizer.svelte'
55
import LoadingIcon from '../LoadingIcon.svelte'
66
import CopyDialog from '../write-operations/CopyDialog.svelte'
7+
import CopyProgressDialog from '../write-operations/CopyProgressDialog.svelte'
8+
import { toBackendIndices, toBackendCursorIndex } from '../write-operations/copy-dialog-utils'
9+
import { formatBytes } from '$lib/tauri-commands'
710
import {
811
loadAppStatus,
912
saveAppStatus,
@@ -85,6 +88,15 @@
8588
sourceFolderPath: string
8689
} | null>(null)
8790
91+
// Copy progress dialog state
92+
let showCopyProgressDialog = $state(false)
93+
let copyProgressProps = $state<{
94+
sourcePaths: string[]
95+
sourceFolderPath: string
96+
destinationPath: string
97+
direction: 'left' | 'right'
98+
} | null>(null)
99+
88100
// Navigation history for each pane (per-pane, session-only)
89101
// Initialize with default volume - will be updated on mount with actual state
90102
let leftHistory = $state<NavigationHistory>(createHistory(DEFAULT_VOLUME_ID, '~'))
@@ -786,9 +798,9 @@
786798
}
787799
788800
/** Gets file paths for the given indices from a listing. */
789-
async function getSelectedFilePaths(listingId: string, indices: number[]): Promise<string[]> {
801+
async function getSelectedFilePaths(listingId: string, backendIndices: number[]): Promise<string[]> {
790802
const paths: string[] = []
791-
for (const index of indices) {
803+
for (const index of backendIndices) {
792804
const file = await getFileAt(listingId, index, showHiddenFiles)
793805
if (file && file.name !== '..') {
794806
paths.push(file.path)
@@ -806,13 +818,15 @@
806818
const listingId = sourcePaneRef?.getListingId?.() as string | undefined
807819
if (!listingId) return
808820
821+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
822+
const hasParent = sourcePaneRef?.hasParentEntry?.() as boolean | undefined
809823
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
810824
const selectedIndices = sourcePaneRef?.getSelectedIndices?.() as number[] | undefined
811825
const hasSelection = selectedIndices && selectedIndices.length > 0
812826
813827
const props = hasSelection
814-
? await buildCopyPropsFromSelection(listingId, selectedIndices, isLeft)
815-
: await buildCopyPropsFromCursor(listingId, sourcePaneRef, isLeft)
828+
? await buildCopyPropsFromSelection(listingId, selectedIndices, hasParent ?? false, isLeft)
829+
: await buildCopyPropsFromCursor(listingId, sourcePaneRef, hasParent ?? false, isLeft)
816830
817831
if (props) {
818832
copyDialogProps = props
@@ -834,10 +848,15 @@
834848
async function buildCopyPropsFromSelection(
835849
listingId: string,
836850
selectedIndices: number[],
851+
hasParent: boolean,
837852
isLeft: boolean,
838853
): Promise<CopyDialogPropsData | null> {
839-
const stats = await getListingStats(listingId, showHiddenFiles, selectedIndices)
840-
const sourcePaths = await getSelectedFilePaths(listingId, selectedIndices)
854+
// Convert frontend indices to backend indices (adjust for ".." entry)
855+
const backendIndices = toBackendIndices(selectedIndices, hasParent)
856+
if (backendIndices.length === 0) return null
857+
858+
const stats = await getListingStats(listingId, showHiddenFiles, backendIndices)
859+
const sourcePaths = await getSelectedFilePaths(listingId, backendIndices)
841860
if (sourcePaths.length === 0) return null
842861
843862
return {
@@ -855,13 +874,15 @@
855874
async function buildCopyPropsFromCursor(
856875
listingId: string,
857876
paneRef: FilePane | undefined,
877+
hasParent: boolean,
858878
isLeft: boolean,
859879
): Promise<CopyDialogPropsData | null> {
860880
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
861881
const cursorIndex = paneRef?.getCursorIndex?.() as number | undefined
862-
if (cursorIndex === undefined || cursorIndex < 0) return null
882+
const backendIndex = toBackendCursorIndex(cursorIndex ?? -1, hasParent)
883+
if (backendIndex === null) return null
863884
864-
const file = await getFileAt(listingId, cursorIndex, showHiddenFiles)
885+
const file = await getFileAt(listingId, backendIndex, showHiddenFiles)
865886
if (!file || file.name === '..') return null
866887
867888
return {
@@ -875,13 +896,21 @@
875896
}
876897
}
877898
878-
function handleCopyConfirm(destination: string, volumeId: string) {
879-
// TODO: Implement actual copy operation using copyFiles() from tauri-commands
880-
const itemCount = copyDialogProps?.sourcePaths.length ?? 0
881-
log.info(`Copy confirmed: ${String(itemCount)} items to ${destination} (volume: ${volumeId})`)
899+
function handleCopyConfirm(destination: string) {
900+
if (!copyDialogProps) return
901+
902+
// Store the props needed for the progress dialog
903+
copyProgressProps = {
904+
sourcePaths: copyDialogProps.sourcePaths,
905+
sourceFolderPath: copyDialogProps.sourceFolderPath,
906+
destinationPath: destination,
907+
direction: copyDialogProps.direction,
908+
}
909+
910+
// Close copy dialog and open progress dialog
882911
showCopyDialog = false
883912
copyDialogProps = null
884-
containerElement?.focus()
913+
showCopyProgressDialog = true
885914
}
886915
887916
function handleCopyCancel() {
@@ -890,6 +919,40 @@
890919
containerElement?.focus()
891920
}
892921
922+
function handleCopyComplete(filesProcessed: number, bytesProcessed: number) {
923+
log.info(`Copy complete: ${String(filesProcessed)} files (${formatBytes(bytesProcessed)})`)
924+
925+
// Refresh the destination pane to show the new files
926+
const destPaneRef = copyProgressProps?.direction === 'right' ? rightPaneRef : leftPaneRef
927+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
928+
destPaneRef?.refreshView?.()
929+
930+
showCopyProgressDialog = false
931+
copyProgressProps = null
932+
containerElement?.focus()
933+
}
934+
935+
function handleCopyCancelled(filesProcessed: number) {
936+
log.info(`Copy cancelled after ${String(filesProcessed)} files`)
937+
938+
// Refresh the destination pane to show any files that were copied
939+
const destPaneRef = copyProgressProps?.direction === 'right' ? rightPaneRef : leftPaneRef
940+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
941+
destPaneRef?.refreshView?.()
942+
943+
showCopyProgressDialog = false
944+
copyProgressProps = null
945+
containerElement?.focus()
946+
}
947+
948+
function handleCopyError(error: string) {
949+
log.error(`Copy failed: ${error}`)
950+
showCopyProgressDialog = false
951+
copyProgressProps = null
952+
// TODO: Show error notification/toast
953+
containerElement?.focus()
954+
}
955+
893956
// Focus the container after initialization so keyboard events work
894957
$effect(() => {
895958
if (initialized) {
@@ -1180,6 +1243,18 @@
11801243
/>
11811244
{/if}
11821245

1246+
{#if showCopyProgressDialog && copyProgressProps}
1247+
<CopyProgressDialog
1248+
sourcePaths={copyProgressProps.sourcePaths}
1249+
sourceFolderPath={copyProgressProps.sourceFolderPath}
1250+
destinationPath={copyProgressProps.destinationPath}
1251+
direction={copyProgressProps.direction}
1252+
onComplete={handleCopyComplete}
1253+
onCancelled={handleCopyCancelled}
1254+
onError={handleCopyError}
1255+
/>
1256+
{/if}
1257+
11831258
<style>
11841259
.dual-pane-explorer {
11851260
display: flex;

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,11 @@
193193
return Array.from(selectedIndices)
194194
}
195195
196+
// Check if the ".." entry is shown (needed for index adjustment in copy/move operations)
197+
export function hasParentEntry(): boolean {
198+
return hasParent
199+
}
200+
196201
// Check if all files are selected (optimization for resort)
197202
export function isAllSelected(): boolean {
198203
const selectableCount = hasParent ? effectiveTotalCount - 1 : effectiveTotalCount

apps/desktop/src/lib/logger.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ const debugCategories: string[] = [
3939
// 'fileExplorer',
4040
// 'dragDrop',
4141
// 'licensing',
42+
'copyProgress', // Enable to debug copy operation progress events
4243
]
4344

4445
/**

0 commit comments

Comments
 (0)