diff --git a/AGENTS.md b/AGENTS.md index d6dcfc079..bfb553f30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,8 @@ cargo run --bin sd-cli -- # 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 diff --git a/packages/interface/src/components/Explorer/views/ColumnView/Column.tsx b/packages/interface/src/components/Explorer/views/ColumnView/Column.tsx index 5ef89b367..35e01fa34 100644 --- a/packages/interface/src/components/Explorer/views/ColumnView/Column.tsx +++ b/packages/interface/src/components/Explorer/views/ColumnView/Column.tsx @@ -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(null); const { viewSettings, sortBy } = useExplorer(); const copyFiles = useLibraryMutation("files.copy"); @@ -153,21 +153,18 @@ export function Column({ path, selectedFile, onSelectFile, onNavigate, nextColum )} style={{ width: `${viewSettings.columnWidth}px` }} > - {files.length === 0 ? ( -
Empty folder
- ) : ( -
+
{rowVirtualizer.getVirtualItems().map((virtualRow) => { 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,19 +188,20 @@ 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); }} />
); })} -
- )} + ); } diff --git a/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx b/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx index cb6bbc892..d486196d2 100644 --- a/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx +++ b/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx @@ -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,42 +32,55 @@ 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 ( - -
- -
- {file.name} - {file.kind === "Directory" && ( - - - - )} -
+
+ +
+ +
+ {file.name} + {file.kind === "Directory" && ( + + + + )} +
+
); } diff --git a/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx b/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx index c77f52a87..56678b9c5 100644 --- a/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx +++ b/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx @@ -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([]); + const isInternalNavigationRef = useRef(false); - // Column-specific selection state (single selection only) - const [selectedFile, setSelectedFile] = useState(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 - if (file.kind === "Directory") { - // Truncate columns after current and add new one - setColumnStack((prev) => [...prev.slice(0, columnIndex + 1), file.sd_path]); - } else { - // For files, just truncate columns after current - setColumnStack((prev) => prev.slice(0, columnIndex + 1)); + // 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 (
{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 ( handleSelectFile(file, index)} + selectedFiles={selectedFiles} + onSelectFile={(file, files, multi, range) => handleSelectFile(file, index, files, multi, range)} onNavigate={handleNavigate} nextColumnPath={columnStack[index + 1]} columnIndex={index}