From 4d10dd5e149a78b0bb2e3bb377b438cdcf448821 Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Thu, 25 Dec 2025 09:17:29 -0800 Subject: [PATCH] Enhance Explorer functionality with context menus and keyboard shortcuts - Added context menu options for creating new folders and pasting files in empty space within the Explorer. - Implemented keyboard shortcuts for entering tag assignment mode and toggling tags 1-10. - Refactored keyboard event handling to streamline tag mode activation and file renaming. - Updated GridView and ListView components to support context menu interactions. --- TODO | 1 + .../components/Explorer/TagAssignmentMode.tsx | 43 ++++------ .../hooks/useEmptySpaceContextMenu.ts | 79 +++++++++++++++++++ .../Explorer/hooks/useExplorerKeyboard.ts | 52 ++++++------ .../Explorer/views/GridView/GridView.tsx | 15 ++++ .../Explorer/views/ListView/ListView.tsx | 12 ++- .../interface/src/util/keybinds/registry.ts | 77 ++++++++++++++++++ 7 files changed, 221 insertions(+), 58 deletions(-) create mode 100644 packages/interface/src/components/Explorer/hooks/useEmptySpaceContextMenu.ts diff --git a/TODO b/TODO index e87ec9497..73926b4c3 100644 --- a/TODO +++ b/TODO @@ -33,6 +33,7 @@ Journey to v2.0.0-pre.1: ☐ Connection info on device panel (lan/relay) ✔ Grid view render bug, shows as column for split second on first render of results @done(25-12-22 07:49) ✔ Job sound: make copy sound a varient, not play also @done(25-12-24 07:24) +☐ Improve job panel data display, layout and ordering @today ☐ Run now button in Location Inspector doesn't work well @today ✔ Sidebar active based on Explorer path @today @done(25-12-20 07:59) ☐ Delete location UX improvement diff --git a/packages/interface/src/components/Explorer/TagAssignmentMode.tsx b/packages/interface/src/components/Explorer/TagAssignmentMode.tsx index bb61a2565..907e7c7fa 100644 --- a/packages/interface/src/components/Explorer/TagAssignmentMode.tsx +++ b/packages/interface/src/components/Explorer/TagAssignmentMode.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { Tag as TagIcon, X } from '@phosphor-icons/react'; import clsx from 'clsx'; import { Button } from '@sd/ui'; import { useNormalizedQuery, useLibraryMutation } from '../../context'; import { useSelection } from './SelectionContext'; +import { useKeybind } from '../../hooks/useKeybind'; import type { Tag } from '@sd/ts-client'; interface TagAssignmentModeProps { @@ -46,34 +47,18 @@ export function TagAssignmentMode({ isActive, onExit }: TagAssignmentModeProps) ) ?? []; const paletteTags = allTags.slice(0, 10) as Tag[]; - // Keyboard shortcuts - useEffect(() => { - if (!isActive) return; - - const handleKeyDown = (e: KeyboardEvent) => { - // Exit on Escape - if (e.key === 'Escape') { - e.preventDefault(); - onExit(); - return; - } - - // Number keys 1-9, 0 - if (e.key >= '1' && e.key <= '9') { - e.preventDefault(); - const index = parseInt(e.key) - 1; - handleToggleTag(index); - } else if (e.key === '0') { - e.preventDefault(); - handleToggleTag(9); - } - - // TODO: Palette switching with Cmd+Shift+[1-9] - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isActive, selectedFiles, paletteTags]); + // Keyboard shortcuts using keybind registry + useKeybind('explorer.exitTagMode', onExit, { enabled: isActive }); + useKeybind('explorer.toggleTag1', () => handleToggleTag(0), { enabled: isActive }); + useKeybind('explorer.toggleTag2', () => handleToggleTag(1), { enabled: isActive }); + useKeybind('explorer.toggleTag3', () => handleToggleTag(2), { enabled: isActive }); + useKeybind('explorer.toggleTag4', () => handleToggleTag(3), { enabled: isActive }); + useKeybind('explorer.toggleTag5', () => handleToggleTag(4), { enabled: isActive }); + useKeybind('explorer.toggleTag6', () => handleToggleTag(5), { enabled: isActive }); + useKeybind('explorer.toggleTag7', () => handleToggleTag(6), { enabled: isActive }); + useKeybind('explorer.toggleTag8', () => handleToggleTag(7), { enabled: isActive }); + useKeybind('explorer.toggleTag9', () => handleToggleTag(8), { enabled: isActive }); + useKeybind('explorer.toggleTag10', () => handleToggleTag(9), { enabled: isActive }); const handleToggleTag = async (index: number) => { const tag = paletteTags[index]; diff --git a/packages/interface/src/components/Explorer/hooks/useEmptySpaceContextMenu.ts b/packages/interface/src/components/Explorer/hooks/useEmptySpaceContextMenu.ts new file mode 100644 index 000000000..905e70f41 --- /dev/null +++ b/packages/interface/src/components/Explorer/hooks/useEmptySpaceContextMenu.ts @@ -0,0 +1,79 @@ +import { FolderPlus, Copy } from "@phosphor-icons/react"; +import { useContextMenu } from "../../../hooks/useContextMenu"; +import { useLibraryMutation } from "../../../context"; +import { useExplorer } from "../context"; +import { useClipboard } from "../../../hooks/useClipboard"; +import { useFileOperationDialog } from "../../FileOperationModal"; + +export function useEmptySpaceContextMenu() { + const { currentPath } = useExplorer(); + const createFolder = useLibraryMutation("files.createFolder"); + const clipboard = useClipboard(); + const openFileOperation = useFileOperationDialog(); + + return useContextMenu({ + items: [ + { + icon: FolderPlus, + label: "New Folder", + onClick: async () => { + if (!currentPath) return; + try { + const result = await createFolder.mutateAsync({ + parent: currentPath, + name: "Untitled Folder", + items: [], + }); + console.log("Created folder:", result); + } catch (err) { + console.error("Failed to create folder:", err); + alert(`Failed to create folder: ${err}`); + } + }, + condition: () => !!currentPath, + }, + { + icon: Copy, + label: "Paste", + onClick: () => { + if (!clipboard.hasClipboard() || !currentPath) { + console.log("[Clipboard] Nothing to paste or no destination"); + return; + } + + const operation = + clipboard.operation === "cut" ? "move" : "copy"; + + console.groupCollapsed( + `[Clipboard] Pasting ${clipboard.files.length} file${clipboard.files.length === 1 ? "" : "s"} (${operation})`, + ); + console.log("Operation:", operation); + console.log("Destination:", currentPath); + console.log("Source files (SdPath objects):"); + clipboard.files.forEach((file, index) => { + console.log(` [${index}]:`, JSON.stringify(file, null, 2)); + }); + console.groupEnd(); + + openFileOperation({ + operation, + sources: clipboard.files, + destination: currentPath, + onComplete: () => { + if (clipboard.operation === "cut") { + console.log( + "[Clipboard] Operation completed, clearing clipboard", + ); + clipboard.clearClipboard(); + } else { + console.log("[Clipboard] Copy operation completed"); + } + }, + }); + }, + keybindId: "explorer.paste", + condition: () => clipboard.hasClipboard(), + }, + ], + }); +} diff --git a/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts b/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts index 62cfeccbb..99fb23c8e 100644 --- a/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts +++ b/packages/interface/src/components/Explorer/hooks/useExplorerKeyboard.ts @@ -128,15 +128,35 @@ export function useExplorerKeyboard() { { enabled: clipboard.hasClipboard() && !!currentPath }, ); - // Rename: Enter key triggers rename mode when single file selected (not directories) + // Rename: Enter key triggers rename mode for any selected file or directory useKeybind( "explorer.renameFile", () => { - if (selectedFiles.length === 1 && !isRenaming && selectedFiles[0].kind !== "Directory") { + if (selectedFiles.length === 1 && !isRenaming) { startRename(selectedFiles[0].id); } }, - { enabled: selectedFiles.length === 1 && !isRenaming && selectedFiles[0]?.kind !== "Directory" }, + { enabled: selectedFiles.length === 1 && !isRenaming }, + ); + + // Tag mode: T key enters tag assignment mode + useKeybind( + "explorer.enterTagMode", + () => { + setTagModeActive(true); + }, + { enabled: !tagModeActive }, + ); + + // Quick Preview: Spacebar opens quick preview + useKeybind( + "explorer.toggleQuickPreview", + () => { + if (selectedFiles.length === 1) { + openQuickPreview(selectedFiles[0].id); + } + }, + { enabled: selectedFiles.length === 1 }, ); useEffect(() => { @@ -206,31 +226,6 @@ export function useExplorerKeyboard() { return; } - // Spacebar: Open Quick Preview (in-app modal) - if (e.code === "Space" && selectedFiles.length === 1) { - e.preventDefault(); - openQuickPreview(selectedFiles[0].id); - return; - } - - // Enter: Navigate into directory (but not if in rename mode - that's handled by keybind) - if (e.key === "Enter" && selectedFiles.length === 1 && !isRenaming) { - const selected = selectedFiles[0]; - if (selected.kind === "Directory") { - e.preventDefault(); - navigateToPath(selected.sd_path); - } - // If it's a file, Enter triggers rename mode (handled by useKeybind above) - return; - } - - // T: Enter tag assignment mode - if (e.key === "t" && !e.metaKey && !e.ctrlKey && !tagModeActive) { - e.preventDefault(); - setTagModeActive(true); - return; - } - // Escape: Clear selection if (e.code === "Escape" && selectedFiles.length > 0) { clearSelection(); @@ -260,5 +255,6 @@ export function useExplorerKeyboard() { setSelectedFiles, openQuickPreview, isRenaming, + typeahead, ]); } diff --git a/packages/interface/src/components/Explorer/views/GridView/GridView.tsx b/packages/interface/src/components/Explorer/views/GridView/GridView.tsx index f81a60046..d094b75a5 100644 --- a/packages/interface/src/components/Explorer/views/GridView/GridView.tsx +++ b/packages/interface/src/components/Explorer/views/GridView/GridView.tsx @@ -7,6 +7,7 @@ import { FileCard } from "./FileCard"; import type { DirectorySortBy, File } from "@sd/ts-client"; import { useVirtualListing } from "../../hooks/useVirtualListing"; import { DragSelect } from "./DragSelect"; +import { useEmptySpaceContextMenu } from "../../hooks/useEmptySpaceContextMenu"; const VIRTUALIZATION_THRESHOLD = 0; // Disabled - always virtualize @@ -23,6 +24,7 @@ export function GridView() { setSelectedFiles, } = useSelection(); const { gridSize, gapSize } = viewSettings; + const emptySpaceContextMenu = useEmptySpaceContextMenu(); // Check for virtual listing first const { files: virtualFiles, isVirtualView } = useVirtualListing(); @@ -59,6 +61,14 @@ export function GridView() { } }; + const handleContainerContextMenu = async (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + e.preventDefault(); + e.stopPropagation(); + await emptySpaceContextMenu.show(e); + } + }; + // Conditional virtualization - use simple grid for small directories const shouldVirtualize = files.length > VIRTUALIZATION_THRESHOLD; const gridContainerRef = useRef(null); @@ -69,6 +79,7 @@ export function GridView() { ref={gridContainerRef} className="h-full overflow-auto" onClick={handleContainerClick} + onContextMenu={handleContainerContextMenu} >
); } @@ -129,6 +141,7 @@ interface VirtualizedGridProps { ) => void; setSelectedFiles: (files: File[]) => void; onContainerClick: (e: React.MouseEvent) => void; + onContainerContextMenu: (e: React.MouseEvent) => void; } function VirtualizedGrid({ @@ -142,6 +155,7 @@ function VirtualizedGrid({ selectFile, setSelectedFiles, onContainerClick, + onContainerContextMenu, }: VirtualizedGridProps) { const parentRef = useRef(null); const [containerWidth, setContainerWidth] = useState(null); @@ -264,6 +278,7 @@ function VirtualizedGrid({ ref={parentRef} className="h-full overflow-auto" onClick={onContainerClick} + onContextMenu={onContainerContextMenu} >
(null); const headerScrollRef = useRef(null); const bodyScrollRef = useRef(null); + const emptySpaceContextMenu = useEmptySpaceContextMenu(); // TODO: Preserve scroll position per tab using scrollPosition from context @@ -96,6 +98,14 @@ export const ListView = memo(function ListView() { } }, []); + const handleContainerContextMenu = async (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + e.preventDefault(); + e.stopPropagation(); + await emptySpaceContextMenu.show(e); + } + }; + // Store values in refs to avoid effect re-runs const rowVirtualizerRef = useRef(rowVirtualizer); rowVirtualizerRef.current = rowVirtualizer; @@ -164,7 +174,7 @@ export const ListView = memo(function ListView() { const totalWidth = table.getTotalSize() + TABLE_PADDING_X * 2; return ( -
+
{/* Sticky Header */}