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:
Jamie Pine 2025-12-08 23:13:23 -08:00
parent 5659e85c09
commit bf1962e5ad
4 changed files with 146 additions and 104 deletions

View File

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

View File

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

View File

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

View File

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