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:
Jamie Pine 2025-12-10 09:06:49 -08:00
parent 64a5894997
commit d8984a0688

View File

@ -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";