mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 13:55:40 -04:00
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:
1
TODO
1
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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user