Skip to content

Commit 281f45e

Browse files
committed
Feature: Add copy dialog with F5 shortcut
- New CopyDialog component with draggable title bar, no blur background - Direction indicator showing source → destination with centered arrow - Volume selector with free space display (new backend API) - Pre-filled destination path, selected for easy editing - ESC cancels, ENTER confirms - Works with selection or file under cursor - TODO: Actual copy operation (dialog just closes for now)
1 parent 7426334 commit 281f45e

12 files changed

Lines changed: 779 additions & 2 deletions

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
"settings-store.ts": { "reason": "Depends on Tauri store APIs" },
3131
"tauri-commands.ts": { "reason": "Tauri command wrappers, tested via integration" },
3232
"updater.svelte.ts": { "reason": "Depends on Tauri updater APIs" },
33-
"window-state.ts": { "reason": "Depends on Tauri window APIs" }
33+
"window-state.ts": { "reason": "Depends on Tauri window APIs" },
34+
"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" }
3436
}
3537
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//! Tauri commands for volume operations.
22
3-
use crate::volumes::{self, DEFAULT_VOLUME_ID, LocationCategory, VolumeInfo};
3+
use crate::volumes::{self, LocationCategory, VolumeInfo, VolumeSpaceInfo, DEFAULT_VOLUME_ID};
44

55
/// Lists all mounted volumes.
66
#[tauri::command]
@@ -41,3 +41,10 @@ pub fn find_containing_volume(path: String) -> Option<VolumeInfo> {
4141

4242
best_match
4343
}
44+
45+
/// Gets space information for a volume at the given path.
46+
/// Returns total and available bytes for the volume.
47+
#[tauri::command]
48+
pub fn get_volume_space(path: String) -> Option<VolumeSpaceInfo> {
49+
volumes::get_volume_space(&path)
50+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,8 @@ pub fn run() {
292292
#[cfg(target_os = "macos")]
293293
commands::volumes::find_containing_volume,
294294
#[cfg(target_os = "macos")]
295+
commands::volumes::get_volume_space,
296+
#[cfg(target_os = "macos")]
295297
commands::network::list_network_hosts,
296298
#[cfg(target_os = "macos")]
297299
commands::network::get_network_discovery_state,

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

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,42 @@ fn get_string_resource(url: &objc2_foundation::NSURL, key: &str) -> Option<Strin
383383
get_nsurl_resource(url, key, |obj| obj.downcast::<NSString>().ok().map(|s| s.to_string()))
384384
}
385385

386+
/// Get a u64 resource value from an NSURL (for capacity values).
387+
fn get_u64_resource(url: &objc2_foundation::NSURL, key: &str) -> Option<u64> {
388+
use objc2_foundation::NSNumber;
389+
get_nsurl_resource(url, key, |obj| {
390+
obj.downcast::<NSNumber>()
391+
.ok()
392+
.map(|n| n.unsignedLongLongValue())
393+
})
394+
}
395+
396+
/// Information about volume space.
397+
#[derive(Debug, Clone, Serialize, Deserialize)]
398+
#[serde(rename_all = "camelCase")]
399+
pub struct VolumeSpaceInfo {
400+
/// Total capacity in bytes.
401+
pub total_bytes: u64,
402+
/// Available capacity in bytes (free space for user).
403+
pub available_bytes: u64,
404+
}
405+
406+
/// Get space information for a volume containing the given path.
407+
pub fn get_volume_space(path: &str) -> Option<VolumeSpaceInfo> {
408+
use objc2_foundation::NSURL;
409+
410+
let url = NSURL::fileURLWithPath(&objc2_foundation::NSString::from_str(path));
411+
412+
let total = get_u64_resource(&url, "NSURLVolumeTotalCapacityKey")?;
413+
let available = get_u64_resource(&url, "NSURLVolumeAvailableCapacityForImportantUsageKey")
414+
.or_else(|| get_u64_resource(&url, "NSURLVolumeAvailableCapacityKey"))?;
415+
416+
Some(VolumeSpaceInfo {
417+
total_bytes: total,
418+
available_bytes: available,
419+
})
420+
}
421+
386422
// Legacy compatibility - maintain VolumeInfo type for backwards compatibility
387423
pub use LocationInfo as VolumeInfo;
388424

@@ -462,4 +498,32 @@ mod tests {
462498
assert_eq!(path_to_id("/"), "root");
463499
assert_eq!(path_to_id("/Volumes/External"), "volumesexternal");
464500
}
501+
502+
#[test]
503+
fn test_get_volume_space_root() {
504+
let space = get_volume_space("/");
505+
assert!(space.is_some(), "Should get space info for root volume");
506+
507+
let space = space.unwrap();
508+
assert!(space.total_bytes > 0, "Total bytes should be positive");
509+
assert!(space.available_bytes > 0, "Available bytes should be positive");
510+
assert!(
511+
space.available_bytes <= space.total_bytes,
512+
"Available should be <= total"
513+
);
514+
}
515+
516+
#[test]
517+
fn test_get_volume_space_home() {
518+
let home = dirs::home_dir().expect("Should have home dir");
519+
let space = get_volume_space(home.to_str().unwrap());
520+
assert!(space.is_some(), "Should get space info for home directory");
521+
}
522+
523+
#[test]
524+
fn test_get_volume_space_nonexistent() {
525+
// Nonexistent paths return None - the NSURL resource API doesn't resolve to ancestor volumes
526+
let space = get_volume_space("/nonexistent/path/that/does/not/exist");
527+
assert!(space.is_none(), "Nonexistent paths should return None");
528+
}
465529
}

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

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import FilePane from './FilePane.svelte'
44
import PaneResizer from './PaneResizer.svelte'
55
import LoadingIcon from '../LoadingIcon.svelte'
6+
import CopyDialog from '../write-operations/CopyDialog.svelte'
67
import {
78
loadAppStatus,
89
saveAppStatus,
@@ -23,6 +24,8 @@
2324
DEFAULT_VOLUME_ID,
2425
type UnlistenFn,
2526
updateFocusedPane,
27+
getFileAt,
28+
getListingStats,
2629
} from '$lib/tauri-commands'
2730
import type { VolumeInfo, SortColumn, SortOrder, NetworkHost } from './types'
2831
import { defaultSortOrders, DEFAULT_SORT_BY } from './types'
@@ -70,6 +73,18 @@
7073
let unlistenVolumeUnmount: UnlistenFn | undefined
7174
let unlistenNavigation: UnlistenFn | undefined
7275
76+
// Copy dialog state
77+
let showCopyDialog = $state(false)
78+
let copyDialogProps = $state<{
79+
sourcePaths: string[]
80+
destinationPath: string
81+
direction: 'left' | 'right'
82+
currentVolumeId: string
83+
fileCount: number
84+
folderCount: number
85+
sourceFolderPath: string
86+
} | null>(null)
87+
7388
// Navigation history for each pane (per-pane, session-only)
7489
// Initialize with default volume - will be updated on mount with actual state
7590
let leftHistory = $state<NavigationHistory>(createHistory(DEFAULT_VOLUME_ID, '~'))
@@ -495,6 +510,13 @@
495510
return
496511
}
497512
513+
// F5 - Copy dialog
514+
if (e.key === 'F5') {
515+
e.preventDefault()
516+
void openCopyDialog()
517+
return
518+
}
519+
498520
// Route to volume chooser if one is open
499521
if (routeToVolumeChooser(e)) {
500522
return
@@ -763,6 +785,111 @@
763785
void saveAppStatus({ leftPaneWidthPercent: 50 })
764786
}
765787
788+
/** Gets file paths for the given indices from a listing. */
789+
async function getSelectedFilePaths(listingId: string, indices: number[]): Promise<string[]> {
790+
const paths: string[] = []
791+
for (const index of indices) {
792+
const file = await getFileAt(listingId, index, showHiddenFiles)
793+
if (file && file.name !== '..') {
794+
paths.push(file.path)
795+
}
796+
}
797+
return paths
798+
}
799+
800+
/** Opens the copy dialog with the current selection info. */
801+
async function openCopyDialog() {
802+
const isLeft = focusedPane === 'left'
803+
const sourcePaneRef = isLeft ? leftPaneRef : rightPaneRef
804+
805+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
806+
const listingId = sourcePaneRef?.getListingId?.() as string | undefined
807+
if (!listingId) return
808+
809+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
810+
const selectedIndices = sourcePaneRef?.getSelectedIndices?.() as number[] | undefined
811+
const hasSelection = selectedIndices && selectedIndices.length > 0
812+
813+
const props = hasSelection
814+
? await buildCopyPropsFromSelection(listingId, selectedIndices, isLeft)
815+
: await buildCopyPropsFromCursor(listingId, sourcePaneRef, isLeft)
816+
817+
if (props) {
818+
copyDialogProps = props
819+
showCopyDialog = true
820+
}
821+
}
822+
823+
type CopyDialogPropsData = {
824+
sourcePaths: string[]
825+
destinationPath: string
826+
direction: 'left' | 'right'
827+
currentVolumeId: string
828+
fileCount: number
829+
folderCount: number
830+
sourceFolderPath: string
831+
}
832+
833+
/** Builds copy dialog props from selected files. */
834+
async function buildCopyPropsFromSelection(
835+
listingId: string,
836+
selectedIndices: number[],
837+
isLeft: boolean,
838+
): Promise<CopyDialogPropsData | null> {
839+
const stats = await getListingStats(listingId, showHiddenFiles, selectedIndices)
840+
const sourcePaths = await getSelectedFilePaths(listingId, selectedIndices)
841+
if (sourcePaths.length === 0) return null
842+
843+
return {
844+
sourcePaths,
845+
destinationPath: isLeft ? rightPath : leftPath,
846+
direction: isLeft ? 'right' : 'left',
847+
currentVolumeId: isLeft ? rightVolumeId : leftVolumeId,
848+
fileCount: stats.selectedFiles ?? 0,
849+
folderCount: stats.selectedDirs ?? 0,
850+
sourceFolderPath: isLeft ? leftPath : rightPath,
851+
}
852+
}
853+
854+
/** Builds copy dialog props from the file under cursor. */
855+
async function buildCopyPropsFromCursor(
856+
listingId: string,
857+
paneRef: FilePane | undefined,
858+
isLeft: boolean,
859+
): Promise<CopyDialogPropsData | null> {
860+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
861+
const cursorIndex = paneRef?.getCursorIndex?.() as number | undefined
862+
if (cursorIndex === undefined || cursorIndex < 0) return null
863+
864+
const file = await getFileAt(listingId, cursorIndex, showHiddenFiles)
865+
if (!file || file.name === '..') return null
866+
867+
return {
868+
sourcePaths: [file.path],
869+
destinationPath: isLeft ? rightPath : leftPath,
870+
direction: isLeft ? 'right' : 'left',
871+
currentVolumeId: isLeft ? rightVolumeId : leftVolumeId,
872+
fileCount: file.isDirectory ? 0 : 1,
873+
folderCount: file.isDirectory ? 1 : 0,
874+
sourceFolderPath: isLeft ? leftPath : rightPath,
875+
}
876+
}
877+
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})`)
882+
showCopyDialog = false
883+
copyDialogProps = null
884+
containerElement?.focus()
885+
}
886+
887+
function handleCopyCancel() {
888+
showCopyDialog = false
889+
copyDialogProps = null
890+
containerElement?.focus()
891+
}
892+
766893
// Focus the container after initialization so keyboard events work
767894
$effect(() => {
768895
if (initialized) {
@@ -1038,6 +1165,21 @@
10381165
{/if}
10391166
</div>
10401167

1168+
{#if showCopyDialog && copyDialogProps}
1169+
<CopyDialog
1170+
sourcePaths={copyDialogProps.sourcePaths}
1171+
destinationPath={copyDialogProps.destinationPath}
1172+
direction={copyDialogProps.direction}
1173+
{volumes}
1174+
currentVolumeId={copyDialogProps.currentVolumeId}
1175+
fileCount={copyDialogProps.fileCount}
1176+
folderCount={copyDialogProps.folderCount}
1177+
sourceFolderPath={copyDialogProps.sourceFolderPath}
1178+
onConfirm={handleCopyConfirm}
1179+
onCancel={handleCopyCancel}
1180+
/>
1181+
{/if}
1182+
10411183
<style>
10421184
.dual-pane-explorer {
10431185
display: flex;

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@
183183
void fetchEntryUnderCursor()
184184
}
185185
186+
// Get current cursor index
187+
export function getCursorIndex(): number {
188+
return cursorIndex
189+
}
190+
186191
// Get selected indices (for selection preservation during re-sort)
187192
export function getSelectedIndices(): number[] {
188193
return Array.from(selectedIndices)

apps/desktop/src/lib/file-explorer/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,14 @@ export interface VolumeInfo {
143143
isEjectable: boolean
144144
}
145145

146+
/** Space information for a volume. */
147+
export interface VolumeSpaceInfo {
148+
/** Total capacity in bytes */
149+
totalBytes: number
150+
/** Available capacity in bytes (free space for user) */
151+
availableBytes: number
152+
}
153+
146154
// ============================================================================
147155
// Sorting types
148156
// ============================================================================

apps/desktop/src/lib/tauri-commands.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -523,6 +523,26 @@ export async function findContainingVolume(path: string): Promise<VolumeInfo | n
523523
}
524524
}
525525

526+
/** Space information for a volume. */
527+
export interface VolumeSpaceInfo {
528+
totalBytes: number
529+
availableBytes: number
530+
}
531+
532+
/**
533+
* Gets space information (total and available bytes) for a volume at the given path.
534+
* @param path - Any path on the volume to get space info for
535+
* @returns Space info or null if unavailable
536+
*/
537+
export async function getVolumeSpace(path: string): Promise<VolumeSpaceInfo | null> {
538+
try {
539+
return await invoke<VolumeSpaceInfo | null>('get_volume_space', { path })
540+
} catch {
541+
// Command not available (non-macOS) - return null
542+
return null
543+
}
544+
}
545+
526546
// ============================================================================
527547
// Permission checking (macOS only)
528548
// ============================================================================

0 commit comments

Comments
 (0)