mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
Enhance FileOperationModal with keyboard shortcuts and improved UI
- Added keyboard shortcuts for file operations: Copy (⌘1), Move (⌘2), Skip (S), Keep both (K), and Overwrite (O). - Improved the visual layout to clearly display source and destination files with an arrow indicator. - Updated operation type selection buttons to include icons and shortcut hints for better user experience. - Refactored conflict resolution options to display keyboard shortcuts alongside labels for clarity. - Introduced a utility function to extract file names from paths for better display in the modal.
This commit is contained in:
parent
64a5894997
commit
d8984a0688
@ -6,6 +6,9 @@ import {
|
||||
Warning,
|
||||
CheckCircle,
|
||||
CircleNotch,
|
||||
ArrowRight,
|
||||
Copy as CopyIcon,
|
||||
ArrowsLeftRight,
|
||||
} from "@phosphor-icons/react";
|
||||
import {
|
||||
Dialog,
|
||||
@ -15,6 +18,7 @@ import {
|
||||
import type { SdPath } from "@sd/ts-client";
|
||||
import { useLibraryMutation } from "../context";
|
||||
import { sounds } from "@sd/assets/sounds";
|
||||
import { File } from "./Explorer/File";
|
||||
|
||||
interface FileOperationDialogProps {
|
||||
id: number;
|
||||
@ -101,6 +105,54 @@ function FileOperationDialog(props: FileOperationDialogProps) {
|
||||
dialogManager.setState(props.id, { open: false });
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (phase.type !== "form") return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Enter - Submit
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle other shortcuts if not typing in an input
|
||||
if ((e.target as HTMLElement)?.tagName === "INPUT") return;
|
||||
|
||||
// ⌘1 / Ctrl+1 - Copy mode
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "1") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOperation("copy");
|
||||
}
|
||||
// ⌘2 / Ctrl+2 - Move mode
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "2") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOperation("move");
|
||||
}
|
||||
// S - Skip
|
||||
if (e.key === "s" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setConflictResolution("Skip");
|
||||
}
|
||||
// K - Keep both
|
||||
if (e.key === "k" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setConflictResolution("AutoModifyName");
|
||||
}
|
||||
// O - Overwrite
|
||||
if (e.key === "o" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setConflictResolution("Overwrite");
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [phase.type, operation, conflictResolution]);
|
||||
|
||||
// Executing state
|
||||
if (phase.type === "executing") {
|
||||
return (
|
||||
@ -147,6 +199,9 @@ function FileOperationDialog(props: FileOperationDialogProps) {
|
||||
);
|
||||
}
|
||||
|
||||
const sourceCount = props.sources.length;
|
||||
const pluralItems = sourceCount === 1 ? "item" : "items";
|
||||
|
||||
// Form state - let user choose operation and conflict resolution
|
||||
return (
|
||||
<Dialog
|
||||
@ -158,46 +213,73 @@ function FileOperationDialog(props: FileOperationDialogProps) {
|
||||
onSubmit={handleSubmit}
|
||||
onCancelled={handleCancel}
|
||||
>
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Destination info */}
|
||||
<div className="flex items-start gap-3 p-3 bg-app rounded-md">
|
||||
<FolderOpen className="size-5 text-accent mt-0.5" weight="fill" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-ink-dull mb-1">Destination:</div>
|
||||
<div className="text-sm text-ink font-medium truncate">
|
||||
{formatDestination(props.destination)}
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Source → Destination visual */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Source */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2 p-3 bg-app rounded-lg">
|
||||
<Files className="size-8 text-ink-dull" weight="fill" />
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-ink-dull mb-0.5">From</div>
|
||||
<div className="text-sm font-medium text-ink">
|
||||
{sourceCount} {pluralItems}
|
||||
</div>
|
||||
{sourceCount === 1 && (
|
||||
<div className="text-xs text-ink-faint mt-1 truncate max-w-full">
|
||||
{getFileName(props.sources[0])}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-ink-faint mt-1">
|
||||
{props.sources.length} {props.sources.length === 1 ? "item" : "items"}
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<div className="flex-shrink-0">
|
||||
<ArrowRight className="size-6 text-accent" weight="bold" />
|
||||
</div>
|
||||
|
||||
{/* Destination */}
|
||||
<div className="flex-1 flex flex-col items-center gap-2 p-3 bg-app rounded-lg">
|
||||
<FolderOpen className="size-8 text-accent" weight="fill" />
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-ink-dull mb-0.5">To</div>
|
||||
<div className="text-sm font-medium text-ink truncate max-w-full">
|
||||
{getFileName(props.destination)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operation type selection */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-ink-dull mb-2">Operation:</div>
|
||||
<div className="text-xs font-medium text-ink-dull mb-2">
|
||||
Operation:
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperation("copy")}
|
||||
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
operation === "copy"
|
||||
? "bg-accent text-white"
|
||||
: "bg-app-box text-ink hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
<CopyIcon className="size-4" weight="bold" />
|
||||
Copy
|
||||
<span className="text-xs opacity-60">⌘1</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOperation("move")}
|
||||
className={`flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
className={`flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
operation === "move"
|
||||
? "bg-accent text-white"
|
||||
: "bg-app-box text-ink hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
<ArrowsLeftRight className="size-4" weight="bold" />
|
||||
Move
|
||||
<span className="text-xs opacity-60">⌘2</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -207,26 +289,31 @@ function FileOperationDialog(props: FileOperationDialogProps) {
|
||||
<div className="text-xs font-medium text-ink-dull mb-2">
|
||||
If files already exist:
|
||||
</div>
|
||||
{[
|
||||
{ value: "Skip", label: "Skip existing files" },
|
||||
{ value: "AutoModifyName", label: "Keep both (rename new files)" },
|
||||
{ value: "Overwrite", label: "Overwrite existing files" },
|
||||
].map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 p-2 rounded-md hover:bg-app-hover cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="conflict-resolution"
|
||||
value={option.value}
|
||||
checked={conflictResolution === option.value}
|
||||
onChange={() => setConflictResolution(option.value as ConflictResolution)}
|
||||
className="size-4 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-ink">{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
<div className="space-y-1">
|
||||
{[
|
||||
{ value: "Skip", label: "Skip existing files", key: "S" },
|
||||
{ value: "AutoModifyName", label: "Keep both (rename new files)", key: "K" },
|
||||
{ value: "Overwrite", label: "Overwrite existing files", key: "O" },
|
||||
].map((option) => (
|
||||
<label
|
||||
key={option.value}
|
||||
className="flex items-center justify-between gap-2 px-2 py-2 rounded-md hover:bg-app-hover cursor-pointer transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="conflict-resolution"
|
||||
value={option.value}
|
||||
checked={conflictResolution === option.value}
|
||||
onChange={() => setConflictResolution(option.value as ConflictResolution)}
|
||||
className="size-4 accent-accent cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-ink">{option.label}</span>
|
||||
</div>
|
||||
<span className="text-xs text-ink-faint font-medium">{option.key}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
@ -234,6 +321,26 @@ function FileOperationDialog(props: FileOperationDialogProps) {
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
function getFileName(path: SdPath): string {
|
||||
if (!path || typeof path !== "object") {
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
if ("Physical" in path && path.Physical) {
|
||||
const pathStr = path.Physical.path || "";
|
||||
const parts = pathStr.split("/");
|
||||
return parts[parts.length - 1] || pathStr;
|
||||
}
|
||||
|
||||
if ("Cloud" in path && path.Cloud) {
|
||||
const pathStr = path.Cloud.path || "";
|
||||
const parts = pathStr.split("/");
|
||||
return parts[parts.length - 1] || pathStr;
|
||||
}
|
||||
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function formatDestination(path: SdPath): string {
|
||||
if (!path || typeof path !== "object") {
|
||||
return "Unknown";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user