mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
Enhance file selection and navigation in Explorer component
- Updated the Column component to support multiple file selection and improved type safety. - Refactored file selection handling to utilize global selection context, allowing for better management of selected files. - Added drag-and-drop functionality for file items using the DnD Kit. - Improved keyboard navigation to ensure selected files remain visible during interactions. - Updated documentation to reflect changes in type safety and file selection requirements.
This commit is contained in:
parent
5659e85c09
commit
bf1962e5ad
@ -28,6 +28,8 @@ cargo run --bin sd-cli -- <command> # Run CLI (binary is sd-cli, not spaced
|
||||
- Using `println!` instead of `tracing` macros (`info!`, `debug!`, etc)
|
||||
- Implementing `Wire` manually instead of using `register_*` macros
|
||||
- Blocking the async runtime with synchronous I/O operations
|
||||
- On frontend apps, such as the interface in React, you must ALWAYS ensure type-safety based on the auto generated TypeScript types from `ts-client`. Never cast to as any or redefine backend types. our hooks are typesafe with correct input/output types, but sometimes you might need to access types directly from the `ts-client`.
|
||||
- If you have changed types on the backend that are public to the frontend (have `Type` derive), then you must regenerate the types using `cargo run --bin generate_typescript_types`
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
|
||||
@ -12,15 +12,15 @@ import { useLibraryMutation } from "../../../../context";
|
||||
|
||||
interface ColumnProps {
|
||||
path: SdPath;
|
||||
selectedFile: File | null;
|
||||
onSelectFile: (file: File) => void;
|
||||
selectedFiles: File[];
|
||||
onSelectFile: (file: File, files: File[], multi?: boolean, range?: boolean) => void;
|
||||
onNavigate: (path: SdPath) => void;
|
||||
nextColumnPath?: SdPath;
|
||||
columnIndex: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export function Column({ path, selectedFile, onSelectFile, onNavigate, nextColumnPath, columnIndex, isActive }: ColumnProps) {
|
||||
export function Column({ path, selectedFiles, onSelectFile, onNavigate, nextColumnPath, columnIndex, isActive }: ColumnProps) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const { viewSettings, sortBy } = useExplorer();
|
||||
const copyFiles = useLibraryMutation("files.copy");
|
||||
@ -153,9 +153,6 @@ export function Column({ path, selectedFile, onSelectFile, onNavigate, nextColum
|
||||
)}
|
||||
style={{ width: `${viewSettings.columnWidth}px` }}
|
||||
>
|
||||
{files.length === 0 ? (
|
||||
<div className="p-4 text-sm text-ink-dull">Empty folder</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
@ -167,7 +164,7 @@ export function Column({ path, selectedFile, onSelectFile, onNavigate, nextColum
|
||||
const file = files[virtualRow.index];
|
||||
|
||||
// Check if this file is selected
|
||||
const fileIsSelected = selectedFile?.id === file.id;
|
||||
const fileIsSelected = selectedFiles.some((f) => f.id === file.id);
|
||||
|
||||
// Check if this file is part of the navigation path
|
||||
const isInPath = nextColumnPath && file.sd_path.Physical && nextColumnPath.Physical
|
||||
@ -191,11 +188,13 @@ export function Column({ path, selectedFile, onSelectFile, onNavigate, nextColum
|
||||
file={file}
|
||||
selected={fileIsSelected || isInPath}
|
||||
focused={false}
|
||||
onClick={() => onSelectFile(file)}
|
||||
onClick={(multi, range) => onSelectFile(file, files, multi, range)}
|
||||
onContextMenu={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelectFile(file);
|
||||
if (!fileIsSelected) {
|
||||
onSelectFile(file, files, false, false);
|
||||
}
|
||||
await contextMenu.show(e);
|
||||
}}
|
||||
/>
|
||||
@ -203,7 +202,6 @@ export function Column({ path, selectedFile, onSelectFile, onNavigate, nextColum
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import clsx from "clsx";
|
||||
import type { File } from "@sd/ts-client";
|
||||
import { useDraggable } from "@dnd-kit/core";
|
||||
import { File as FileComponent } from "../../File";
|
||||
|
||||
interface ColumnItemProps {
|
||||
file: File;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
onClick: () => void;
|
||||
onClick: (multi: boolean, range: boolean) => void;
|
||||
onDoubleClick?: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
@ -19,8 +20,10 @@ export function ColumnItem({
|
||||
onDoubleClick,
|
||||
onContextMenu,
|
||||
}: ColumnItemProps) {
|
||||
const handleClick = () => {
|
||||
onClick();
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
const multi = e.metaKey || e.ctrlKey;
|
||||
const range = e.shiftKey;
|
||||
onClick(multi, range);
|
||||
};
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
@ -29,7 +32,18 @@ export function ColumnItem({
|
||||
}
|
||||
};
|
||||
|
||||
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
|
||||
id: file.id,
|
||||
data: {
|
||||
type: "explorer-file",
|
||||
sdPath: file.sd_path,
|
||||
name: file.name,
|
||||
file: file,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
<FileComponent
|
||||
file={file}
|
||||
selected={selected}
|
||||
@ -43,7 +57,8 @@ export function ColumnItem({
|
||||
selected
|
||||
? "bg-accent text-white"
|
||||
: "text-ink",
|
||||
focused && !selected && "ring-2 ring-accent/50"
|
||||
focused && !selected && "ring-2 ring-accent/50",
|
||||
isDragging && "opacity-50"
|
||||
)}
|
||||
>
|
||||
<div className="[&_*]:!rounded-[3px] flex-shrink-0">
|
||||
@ -66,5 +81,6 @@ export function ColumnItem({
|
||||
</svg>
|
||||
)}
|
||||
</FileComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import type { SdPath, File } from "@sd/ts-client";
|
||||
import { useExplorer } from "../../context";
|
||||
import { useSelection } from "../../SelectionContext";
|
||||
@ -8,53 +8,56 @@ import { Column } from "./Column";
|
||||
|
||||
export function ColumnView() {
|
||||
const { currentPath, setCurrentPath, sortBy, viewSettings } = useExplorer();
|
||||
const { clearSelection, setSelectedFiles } = useSelection();
|
||||
const { selectedFiles, selectFile, clearSelection } = useSelection();
|
||||
const [columnStack, setColumnStack] = useState<SdPath[]>([]);
|
||||
const isInternalNavigationRef = useRef(false);
|
||||
|
||||
// Column-specific selection state (single selection only)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
|
||||
// Sync local selection with global selection context (for inspector)
|
||||
// Initialize column stack when currentPath changes externally
|
||||
useEffect(() => {
|
||||
if (selectedFile) {
|
||||
setSelectedFiles([selectedFile]);
|
||||
} else {
|
||||
clearSelection();
|
||||
}
|
||||
}, [selectedFile, setSelectedFiles, clearSelection]);
|
||||
|
||||
// Initialize column stack when currentPath changes
|
||||
useEffect(() => {
|
||||
if (currentPath) {
|
||||
// Only reset if this is an external navigation (not from within column view)
|
||||
if (currentPath && !isInternalNavigationRef.current) {
|
||||
setColumnStack([currentPath]);
|
||||
setSelectedFile(null);
|
||||
clearSelection();
|
||||
}
|
||||
isInternalNavigationRef.current = false;
|
||||
}, [currentPath, clearSelection]);
|
||||
|
||||
// Handle file selection - updates columns
|
||||
const handleSelectFile = useCallback((file: File, columnIndex: number) => {
|
||||
setSelectedFile(file);
|
||||
// Handle file selection - uses global selectFile and updates columns
|
||||
const handleSelectFile = useCallback((file: File, columnIndex: number, files: File[], multi = false, range = false) => {
|
||||
// Use global selectFile to update selection state
|
||||
selectFile(file, files, multi, range);
|
||||
|
||||
// If it's a directory, add a new column
|
||||
// Only update columns for single directory selection
|
||||
if (!multi && !range) {
|
||||
if (file.kind === "Directory") {
|
||||
// Truncate columns after current and add new one
|
||||
setColumnStack((prev) => [...prev.slice(0, columnIndex + 1), file.sd_path]);
|
||||
// Update currentPath to the selected directory
|
||||
isInternalNavigationRef.current = true;
|
||||
setCurrentPath(file.sd_path);
|
||||
} else {
|
||||
// For files, just truncate columns after current
|
||||
setColumnStack((prev) => prev.slice(0, columnIndex + 1));
|
||||
// Update currentPath to the file's parent directory
|
||||
const parentPath = columnStack[columnIndex];
|
||||
if (parentPath) {
|
||||
isInternalNavigationRef.current = true;
|
||||
setCurrentPath(parentPath);
|
||||
}
|
||||
}, []);
|
||||
}
|
||||
}
|
||||
}, [selectFile, setCurrentPath, columnStack]);
|
||||
|
||||
const handleNavigate = useCallback((path: SdPath) => {
|
||||
setCurrentPath(path);
|
||||
}, [setCurrentPath]);
|
||||
|
||||
// Find the active column (the one containing the selected file)
|
||||
// Find the active column (the one containing the first selected file)
|
||||
const activeColumnIndex = useMemo(() => {
|
||||
if (!selectedFile) return columnStack.length - 1; // Default to last column
|
||||
if (selectedFiles.length === 0) return columnStack.length - 1; // Default to last column
|
||||
|
||||
const filePath = selectedFile.sd_path.Physical?.path;
|
||||
const firstSelected = selectedFiles[0];
|
||||
const filePath = firstSelected.sd_path.Physical?.path;
|
||||
if (!filePath) return columnStack.length - 1;
|
||||
|
||||
const fileParent = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
@ -63,7 +66,7 @@ export function ColumnView() {
|
||||
const columnPath = path.Physical?.path;
|
||||
return columnPath === fileParent;
|
||||
});
|
||||
}, [selectedFile, columnStack]);
|
||||
}, [selectedFiles, columnStack]);
|
||||
|
||||
const activeColumnPath = columnStack[activeColumnIndex];
|
||||
|
||||
@ -119,8 +122,8 @@ export function ColumnView() {
|
||||
// Navigate within current column
|
||||
if (activeColumnFiles.length === 0) return;
|
||||
|
||||
const currentIndex = selectedFile
|
||||
? activeColumnFiles.findIndex((f) => f.id === selectedFile.id)
|
||||
const currentIndex = selectedFiles.length > 0
|
||||
? activeColumnFiles.findIndex((f) => f.id === selectedFiles[0].id)
|
||||
: -1;
|
||||
|
||||
const newIndex = e.key === "ArrowDown"
|
||||
@ -128,21 +131,44 @@ export function ColumnView() {
|
||||
: currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0);
|
||||
|
||||
if (newIndex !== currentIndex && activeColumnFiles[newIndex]) {
|
||||
handleSelectFile(activeColumnFiles[newIndex], activeColumnIndex);
|
||||
const newFile = activeColumnFiles[newIndex];
|
||||
handleSelectFile(newFile, activeColumnIndex, activeColumnFiles);
|
||||
|
||||
// Scroll to keep selection visible
|
||||
const element = document.querySelector(`[data-file-id="${newFile.id}"]`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
// Move to previous column
|
||||
if (activeColumnIndex > 0) {
|
||||
const previousColumnPath = columnStack[activeColumnIndex - 1];
|
||||
// Truncate columns and stay at previous column
|
||||
setColumnStack((prev) => prev.slice(0, activeColumnIndex));
|
||||
setSelectedFile(null);
|
||||
clearSelection();
|
||||
// Update currentPath to previous column
|
||||
if (previousColumnPath) {
|
||||
isInternalNavigationRef.current = true;
|
||||
setCurrentPath(previousColumnPath);
|
||||
}
|
||||
}
|
||||
} else if (e.key === "ArrowRight") {
|
||||
// If selected file is a directory and there's a next column, move focus there
|
||||
if (selectedFile?.kind === "Directory" && activeColumnIndex < columnStack.length - 1) {
|
||||
const firstSelected = selectedFiles[0];
|
||||
if (firstSelected?.kind === "Directory" && activeColumnIndex < columnStack.length - 1) {
|
||||
// Select first item in next column
|
||||
if (nextColumnFiles.length > 0) {
|
||||
handleSelectFile(nextColumnFiles[0], activeColumnIndex + 1);
|
||||
const firstFile = nextColumnFiles[0];
|
||||
handleSelectFile(firstFile, activeColumnIndex + 1, nextColumnFiles);
|
||||
|
||||
// Scroll to keep selection visible
|
||||
setTimeout(() => {
|
||||
const element = document.querySelector(`[data-file-id="${firstFile.id}"]`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -150,7 +176,7 @@ export function ColumnView() {
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [activeColumnFiles, nextColumnFiles, selectedFile, activeColumnIndex, columnStack, handleSelectFile]);
|
||||
}, [activeColumnFiles, nextColumnFiles, selectedFiles, activeColumnIndex, columnStack, handleSelectFile]);
|
||||
|
||||
if (!currentPath) {
|
||||
return (
|
||||
@ -163,24 +189,24 @@ export function ColumnView() {
|
||||
return (
|
||||
<div className="flex h-full overflow-x-auto bg-app">
|
||||
{columnStack.map((path, index) => {
|
||||
// A column is active if it contains the selected file or is the last column with no selection
|
||||
const isActive = selectedFile
|
||||
? // Check if selected file's parent path matches this column's path
|
||||
(() => {
|
||||
const filePath = selectedFile.sd_path.Physical?.path;
|
||||
// A column is active if it contains a selected file or is the last column with no selection
|
||||
const isActive = selectedFiles.length > 0
|
||||
? // Check if any selected file's parent path matches this column's path
|
||||
selectedFiles.some((file) => {
|
||||
const filePath = file.sd_path.Physical?.path;
|
||||
const columnPath = path.Physical?.path;
|
||||
if (!filePath || !columnPath) return false;
|
||||
const fileParent = filePath.substring(0, filePath.lastIndexOf('/'));
|
||||
return fileParent === columnPath;
|
||||
})()
|
||||
})
|
||||
: index === columnStack.length - 1; // Last column is active if no selection
|
||||
|
||||
return (
|
||||
<Column
|
||||
key={`${path.Physical?.device_slug}-${path.Physical?.path}-${index}`}
|
||||
path={path}
|
||||
selectedFile={selectedFile}
|
||||
onSelectFile={(file) => handleSelectFile(file, index)}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectFile={(file, files, multi, range) => handleSelectFile(file, index, files, multi, range)}
|
||||
onNavigate={handleNavigate}
|
||||
nextColumnPath={columnStack[index + 1]}
|
||||
columnIndex={index}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user