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.
This commit is contained in:
Jamie Pine
2025-12-25 09:17:29 -08:00
parent 4f57c23ba2
commit 4d10dd5e14
7 changed files with 221 additions and 58 deletions

1
TODO
View File

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

View File

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

View File

@@ -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(),
},
],
});
}

View File

@@ -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,
]);
}

View File

@@ -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<HTMLDivElement>(null);
@@ -69,6 +79,7 @@ export function GridView() {
ref={gridContainerRef}
className="h-full overflow-auto"
onClick={handleContainerClick}
onContextMenu={handleContainerContextMenu}
>
<DragSelect files={files} scrollRef={gridContainerRef}>
<div
@@ -109,6 +120,7 @@ export function GridView() {
selectFile={selectFile}
setSelectedFiles={setSelectedFiles}
onContainerClick={handleContainerClick}
onContainerContextMenu={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<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState<number | null>(null);
@@ -264,6 +278,7 @@ function VirtualizedGrid({
ref={parentRef}
className="h-full overflow-auto"
onClick={onContainerClick}
onContextMenu={onContainerContextMenu}
>
<DragSelect files={files} scrollRef={parentRef}>
<div

View File

@@ -19,6 +19,7 @@ import {
} from "./useTable";
import { useVirtualListing } from "../../hooks/useVirtualListing";
import { DragSelect } from "./DragSelect";
import { useEmptySpaceContextMenu } from "../../hooks/useEmptySpaceContextMenu";
export const ListView = memo(function ListView() {
const { currentPath, sortBy, setSortBy, viewSettings, setCurrentFiles } =
@@ -36,6 +37,7 @@ export const ListView = memo(function ListView() {
const containerRef = useRef<HTMLDivElement>(null);
const headerScrollRef = useRef<HTMLDivElement>(null);
const bodyScrollRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="h-full overflow-auto">
<div ref={containerRef} className="h-full overflow-auto" onContextMenu={handleContainerContextMenu}>
<DragSelect files={files} scrollRef={containerRef}>
{/* Sticky Header */}
<div

View File

@@ -187,6 +187,83 @@ export const explorerKeybinds = {
scope: 'explorer'
}),
exitTagMode: defineKeybind({
id: 'explorer.exitTagMode',
label: 'Exit Tag Mode',
combo: { modifiers: [], key: 'Escape' },
scope: 'explorer'
}),
toggleTag1: defineKeybind({
id: 'explorer.toggleTag1',
label: 'Toggle Tag 1',
combo: { modifiers: [], key: '1' },
scope: 'explorer'
}),
toggleTag2: defineKeybind({
id: 'explorer.toggleTag2',
label: 'Toggle Tag 2',
combo: { modifiers: [], key: '2' },
scope: 'explorer'
}),
toggleTag3: defineKeybind({
id: 'explorer.toggleTag3',
label: 'Toggle Tag 3',
combo: { modifiers: [], key: '3' },
scope: 'explorer'
}),
toggleTag4: defineKeybind({
id: 'explorer.toggleTag4',
label: 'Toggle Tag 4',
combo: { modifiers: [], key: '4' },
scope: 'explorer'
}),
toggleTag5: defineKeybind({
id: 'explorer.toggleTag5',
label: 'Toggle Tag 5',
combo: { modifiers: [], key: '5' },
scope: 'explorer'
}),
toggleTag6: defineKeybind({
id: 'explorer.toggleTag6',
label: 'Toggle Tag 6',
combo: { modifiers: [], key: '6' },
scope: 'explorer'
}),
toggleTag7: defineKeybind({
id: 'explorer.toggleTag7',
label: 'Toggle Tag 7',
combo: { modifiers: [], key: '7' },
scope: 'explorer'
}),
toggleTag8: defineKeybind({
id: 'explorer.toggleTag8',
label: 'Toggle Tag 8',
combo: { modifiers: [], key: '8' },
scope: 'explorer'
}),
toggleTag9: defineKeybind({
id: 'explorer.toggleTag9',
label: 'Toggle Tag 9',
combo: { modifiers: [], key: '9' },
scope: 'explorer'
}),
toggleTag10: defineKeybind({
id: 'explorer.toggleTag10',
label: 'Toggle Tag 10',
combo: { modifiers: [], key: '0' },
scope: 'explorer'
}),
// Clear selection
clearSelection: defineKeybind({
id: 'explorer.clearSelection',