diff --git a/apps/mobile/modules/sd-mobile-core/core/Cargo.lock b/apps/mobile/modules/sd-mobile-core/core/Cargo.lock index fbd6cb578..385de592e 100644 Binary files a/apps/mobile/modules/sd-mobile-core/core/Cargo.lock and b/apps/mobile/modules/sd-mobile-core/core/Cargo.lock differ diff --git a/apps/tauri/src/App.tsx b/apps/tauri/src/App.tsx index 7bfd52e9b..0d79f94fa 100644 --- a/apps/tauri/src/App.tsx +++ b/apps/tauri/src/App.tsx @@ -2,14 +2,14 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { - Explorer, - FloatingControls, - LocationCacheDemo, - PopoutInspector, - QuickPreview, - Settings, - PlatformProvider, - SpacedriveProvider, + Explorer, + FloatingControls, + LocationCacheDemo, + PopoutInspector, + QuickPreview, + Settings, + PlatformProvider, + SpacedriveProvider, } from "@sd/interface"; import { SpacedriveClient, TauriTransport } from "@sd/ts-client"; import { sounds } from "@sd/assets/sounds"; @@ -22,188 +22,192 @@ import { platform } from "./platform"; import { initializeContextMenuHandler } from "./contextMenu"; function App() { - const [client, setClient] = useState(null); - const [error, setError] = useState(null); - const [route, setRoute] = useState("/"); + const [client, setClient] = useState(null); + const [error, setError] = useState(null); + const [route, setRoute] = useState("/"); - useEffect(() => { - // React Scan disabled - too heavy for development - // Uncomment if you need to debug render performance: - // if (import.meta.env.DEV) { - // setTimeout(() => { - // import("react-scan").then(({ scan }) => { - // scan({ enabled: true, log: false }); - // }); - // }, 2000); - // } + useEffect(() => { + // React Scan disabled - too heavy for development + // Uncomment if you need to debug render performance: + if (import.meta.env.DEV) { + setTimeout(() => { + import("react-scan").then(({ scan }) => { + scan({ enabled: true, log: false }); + }); + }, 2000); + } - // Initialize Tauri native context menu handler - initializeContextMenuHandler(); + // Initialize Tauri native context menu handler + initializeContextMenuHandler(); - // Prevent default context menu globally (except in context menu windows) - const currentWindow = getCurrentWebviewWindow(); - const label = currentWindow.label; + // Prevent default context menu globally (except in context menu windows) + const currentWindow = getCurrentWebviewWindow(); + const label = currentWindow.label; - // Prevent default browser context menu globally (except in context menu windows) - if (!label.startsWith("context-menu")) { - const preventContextMenu = (e: Event) => { - // Default behavior: prevent browser context menu - // React's onContextMenu handlers can override this with their own preventDefault - e.preventDefault(); - }; - document.addEventListener("contextmenu", preventContextMenu, { - capture: false, - }); - } + // Prevent default browser context menu globally (except in context menu windows) + if (!label.startsWith("context-menu")) { + const preventContextMenu = (e: Event) => { + // Default behavior: prevent browser context menu + // React's onContextMenu handlers can override this with their own preventDefault + e.preventDefault(); + }; + document.addEventListener("contextmenu", preventContextMenu, { + capture: false, + }); + } - // Set route based on window label - if (label === "floating-controls") { - setRoute("/floating-controls"); - } else if (label.startsWith("drag-overlay")) { - setRoute("/drag-overlay"); - } else if (label.startsWith("context-menu")) { - setRoute("/contextmenu"); - } else if (label.startsWith("drag-demo")) { - setRoute("/drag-demo"); - } else if (label.startsWith("spacedrop")) { - setRoute("/spacedrop"); - } else if (label.startsWith("settings")) { - setRoute("/settings"); - } else if (label.startsWith("inspector")) { - setRoute("/inspector"); - } else if (label.startsWith("quick-preview")) { - setRoute("/quick-preview"); - } else if (label.startsWith("cache-demo")) { - setRoute("/cache-demo"); - } + // Set route based on window label + if (label === "floating-controls") { + setRoute("/floating-controls"); + } else if (label.startsWith("drag-overlay")) { + setRoute("/drag-overlay"); + } else if (label.startsWith("context-menu")) { + setRoute("/contextmenu"); + } else if (label.startsWith("drag-demo")) { + setRoute("/drag-demo"); + } else if (label.startsWith("spacedrop")) { + setRoute("/spacedrop"); + } else if (label.startsWith("settings")) { + setRoute("/settings"); + } else if (label.startsWith("inspector")) { + setRoute("/inspector"); + } else if (label.startsWith("quick-preview")) { + setRoute("/quick-preview"); + } else if (label.startsWith("cache-demo")) { + setRoute("/cache-demo"); + } - // Tell Tauri window is ready to be shown - invoke("app_ready").catch(console.error); + // Tell Tauri window is ready to be shown + invoke("app_ready").catch(console.error); - // Play startup sound - // sounds.startup(); + // Play startup sound + // sounds.startup(); - // Create Tauri-based client - try { - const transport = new TauriTransport(invoke, listen); - const spacedrive = new SpacedriveClient(transport); - setClient(spacedrive); + // Create Tauri-based client + try { + const transport = new TauriTransport(invoke, listen); + const spacedrive = new SpacedriveClient(transport); + setClient(spacedrive); - // Query current library ID from platform state (for popout windows) - if (platform.getCurrentLibraryId) { - platform - .getCurrentLibraryId() - .then((libraryId) => { - if (libraryId) { - spacedrive.setCurrentLibrary(libraryId, false); // Don't emit - already in sync - } - }) - .catch(() => { - // Library not selected yet - this is fine for initial load - }); - } + // Query current library ID from platform state (for popout windows) + if (platform.getCurrentLibraryId) { + platform + .getCurrentLibraryId() + .then((libraryId) => { + if (libraryId) { + spacedrive.setCurrentLibrary(libraryId, false); // Don't emit - already in sync + } + }) + .catch(() => { + // Library not selected yet - this is fine for initial load + }); + } - // Listen for library-changed events via platform (emitted when library switches) - if (platform.onLibraryIdChanged) { - platform.onLibraryIdChanged((newLibraryId) => { - spacedrive.setCurrentLibrary(newLibraryId, true); // DO emit - hooks need to know! - }); - } + // Listen for library-changed events via platform (emitted when library switches) + if (platform.onLibraryIdChanged) { + platform.onLibraryIdChanged((newLibraryId) => { + spacedrive.setCurrentLibrary(newLibraryId, true); // DO emit - hooks need to know! + }); + } - // No global subscription needed - each useNormalizedCache creates its own filtered subscription - } catch (err) { - console.error("Failed to create client:", err); - setError(err instanceof Error ? err.message : String(err)); - } - }, []); + // No global subscription needed - each useNormalizedCache creates its own filtered subscription + } catch (err) { + console.error("Failed to create client:", err); + setError(err instanceof Error ? err.message : String(err)); + } + }, []); - // Routes that don't need the client - if (route === "/floating-controls") { - return ; - } + // Routes that don't need the client + if (route === "/floating-controls") { + return ; + } - if (route === "/drag-overlay") { - return ; - } + if (route === "/drag-overlay") { + return ; + } - if (route === "/contextmenu") { - return ; - } + if (route === "/contextmenu") { + return ; + } - if (route === "/drag-demo") { - return ; - } + if (route === "/drag-demo") { + return ; + } - if (route === "/spacedrop") { - return ; - } + if (route === "/spacedrop") { + return ; + } - if (error) { - console.log("Rendering error state"); - return ( -
-
-

Error

-

{error}

-
-
- ); - } + if (error) { + console.log("Rendering error state"); + return ( +
+
+

Error

+

{error}

+
+
+ ); + } - if (!client) { - console.log("Rendering loading state"); - return ( -
-
-
Initializing client...
-

Check console for logs

-
-
- ); - } + if (!client) { + console.log("Rendering loading state"); + return ( +
+
+
+ Initializing client... +
+

+ Check console for logs +

+
+
+ ); + } - console.log("Rendering Interface with client"); + console.log("Rendering Interface with client"); - // Route to different UIs based on window type - if (route === "/settings") { - return ( - - - - - - ); - } + // Route to different UIs based on window type + if (route === "/settings") { + return ( + + + + + + ); + } - if (route === "/inspector") { - return ( - - -
- -
-
-
- ); - } + if (route === "/inspector") { + return ( + + +
+ +
+
+
+ ); + } - if (route === "/cache-demo") { - return ; - } + if (route === "/cache-demo") { + return ; + } - if (route === "/quick-preview") { - return ( -
- -
- ); - } + if (route === "/quick-preview") { + return ( +
+ +
+ ); + } - return ( - - - - ); + return ( + + + + ); } export default App; diff --git a/packages/interface/src/Explorer.tsx b/packages/interface/src/Explorer.tsx index c52dc3afe..54082b1c8 100644 --- a/packages/interface/src/Explorer.tsx +++ b/packages/interface/src/Explorer.tsx @@ -6,7 +6,7 @@ import { useLocation, useParams, } from "react-router-dom"; -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, memo } from "react"; import { Dialogs } from "@sd/ui"; import { Inspector, type InspectorVariant } from "./Inspector"; import { TopBarProvider, TopBar } from "./TopBar"; @@ -29,7 +29,11 @@ import { PREVIEW_LAYER_ID, } from "./components/QuickPreview"; import { createExplorerRouter } from "./router"; -import { useNormalizedQuery, useLibraryMutation, useSpacedriveClient } from "./context"; +import { + useNormalizedQuery, + useLibraryMutation, + useSpacedriveClient, +} from "./context"; import { useSidebarStore } from "@sd/ts-client"; import { useSpaces } from "./components/SpacesSidebar/hooks/useSpaces"; import { useQueryClient } from "@tanstack/react-query"; @@ -51,6 +55,96 @@ import { File as FileComponent } from "./components/Explorer/File"; import { DaemonDisconnectedOverlay } from "./components/DaemonDisconnectedOverlay"; import { useFileOperationDialog } from "./components/FileOperationModal"; +/** + * QuickPreviewSyncer - Syncs selection changes to QuickPreview + * + * This component is isolated so selection changes only re-render this tiny component, + * not the entire ExplorerLayout. When selection changes while QuickPreview is open, + * we update the preview to show the newly selected file. + */ +function QuickPreviewSyncer() { + const { quickPreviewFileId, setQuickPreviewFileId } = useExplorer(); + const { selectedFiles } = useSelection(); + + useEffect(() => { + if (!quickPreviewFileId) return; + + // When selection changes and QuickPreview is open, update preview to match selection + if ( + selectedFiles.length === 1 && + selectedFiles[0].id !== quickPreviewFileId + ) { + setQuickPreviewFileId(selectedFiles[0].id); + } + }, [selectedFiles, quickPreviewFileId, setQuickPreviewFileId]); + + return null; +} + +/** + * QuickPreviewController - Handles QuickPreview with navigation + * + * Isolated component that reads selection state for prev/next navigation. + * Only re-renders when quickPreviewFileId changes, not on every selection change. + */ +const QuickPreviewController = memo(function QuickPreviewController({ + sidebarWidth, + inspectorWidth, +}: { + sidebarWidth: number; + inspectorWidth: number; +}) { + const { quickPreviewFileId, closeQuickPreview, currentFiles } = + useExplorer(); + const { selectFile } = useSelection(); + + // Early return if no preview - this component won't re-render on selection changes + // because it's memoized and doesn't read selectedFiles directly + if (!quickPreviewFileId) return null; + + const currentIndex = currentFiles.findIndex( + (f) => f.id === quickPreviewFileId, + ); + const hasPrevious = currentIndex > 0; + const hasNext = currentIndex < currentFiles.length - 1; + + const handleNext = () => { + if (hasNext && currentFiles[currentIndex + 1]) { + selectFile( + currentFiles[currentIndex + 1], + currentFiles, + false, + false, + ); + } + }; + + const handlePrevious = () => { + if (hasPrevious && currentFiles[currentIndex - 1]) { + selectFile( + currentFiles[currentIndex - 1], + currentFiles, + false, + false, + ); + } + }; + + return ( + + ); +}); + interface AppProps { client: SpacedriveClient; } @@ -64,15 +158,11 @@ export function ExplorerLayout() { inspectorVisible, setInspectorVisible, quickPreviewFileId, - setQuickPreviewFileId, - closeQuickPreview, - currentFiles, tagModeActive, setTagModeActive, viewMode, setSpaceItemId, } = useExplorer(); - const { selectedFiles, selectFile } = useSelection(); // Sync route with explorer context for view preferences useEffect(() => { @@ -83,19 +173,6 @@ export function ExplorerLayout() { setSpaceItemId(spaceItemKey); }, [location.pathname, location.search, setSpaceItemId]); - // Sync QuickPreview with selection - Explorer is source of truth - useEffect(() => { - if (!quickPreviewFileId) return; - - // When selection changes and QuickPreview is open, update preview to match selection - if ( - selectedFiles.length === 1 && - selectedFiles[0].id !== quickPreviewFileId - ) { - setQuickPreviewFileId(selectedFiles[0].id); - } - }, [selectedFiles, quickPreviewFileId, setQuickPreviewFileId]); - // Check if we're on Overview (hide inspector) or in Knowledge view (has its own inspector) const isOverview = location.pathname === "/"; const isKnowledgeView = viewMode === "knowledge"; @@ -208,6 +285,9 @@ export function ExplorerLayout() { {/* Keyboard handler (invisible, doesn't cause parent rerenders) */} + {/* Syncs selection to QuickPreview - isolated to prevent frame rerenders */} + + {/* Hide inspector on Overview screen and Knowledge view (has its own) */} {inspectorVisible && !isOverview && !isKnowledgeView && ( @@ -229,57 +309,15 @@ export function ExplorerLayout() { )} - {/* Quick Preview - renders via portal into preview layer */} - {quickPreviewFileId && - (() => { - const currentIndex = currentFiles.findIndex( - (f) => f.id === quickPreviewFileId, - ); - const hasPrevious = currentIndex > 0; - const hasNext = currentIndex < currentFiles.length - 1; - - const handleNext = () => { - if (hasNext && currentFiles[currentIndex + 1]) { - selectFile( - currentFiles[currentIndex + 1], - currentFiles, - false, - false, - ); - } - }; - - const handlePrevious = () => { - if (hasPrevious && currentFiles[currentIndex - 1]) { - selectFile( - currentFiles[currentIndex - 1], - currentFiles, - false, - false, - ); - } - }; - - return ( - - ); - })()} + {/* Quick Preview - isolated component to prevent frame rerenders on selection change */} + ); } @@ -362,11 +400,17 @@ function DndWrapper({ children }: { children: React.ReactNode }) { }); const libraryId = client.getCurrentLibraryId(); - const currentSpace = spaces?.find((s: any) => s.id === currentSpaceId) ?? spaces?.[0]; + const currentSpace = + spaces?.find((s: any) => s.id === currentSpaceId) ?? + spaces?.[0]; if (!currentSpace || !libraryId) return; - const queryKey = ['query:spaces.get_layout', libraryId, { space_id: currentSpace.id }]; + const queryKey = [ + "query:spaces.get_layout", + libraryId, + { space_id: currentSpace.id }, + ]; const layout = queryClient.getQueryData(queryKey) as any; if (!layout) return; @@ -378,10 +422,16 @@ function DndWrapper({ children }: { children: React.ReactNode }) { if (isGroupReorder) { console.log("[DnD] Reordering groups"); - const oldIndex = groups.findIndex((g: any) => g.id === active.id); + const oldIndex = groups.findIndex( + (g: any) => g.id === active.id, + ); const newIndex = groups.findIndex((g: any) => g.id === over.id); - if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + if ( + oldIndex !== -1 && + newIndex !== -1 && + oldIndex !== newIndex + ) { // Optimistically update the UI const newGroups = [...layout.groups]; const [movedGroup] = newGroups.splice(oldIndex, 1); @@ -412,18 +462,22 @@ function DndWrapper({ children }: { children: React.ReactNode }) { // Reordering space items if (layout?.space_items) { const items = layout.space_items; - const oldIndex = items.findIndex((item: any) => item.id === active.id); + const oldIndex = items.findIndex( + (item: any) => item.id === active.id, + ); // Extract item ID from over.id (could be a drop zone ID like "space-item-{id}-top") let overItemId = String(over.id); - if (overItemId.startsWith('space-item-')) { + if (overItemId.startsWith("space-item-")) { // Extract the UUID from "space-item-{uuid}-top/bottom/middle" - const parts = overItemId.split('-'); + const parts = overItemId.split("-"); // Remove "space" and "item" and the last part (top/bottom/middle) - overItemId = parts.slice(2, -1).join('-'); + overItemId = parts.slice(2, -1).join("-"); } - const newIndex = items.findIndex((item: any) => item.id === overItemId); + const newIndex = items.findIndex( + (item: any) => item.id === overItemId, + ); console.log("[DnD] Reorder space items:", { oldIndex, @@ -432,7 +486,11 @@ function DndWrapper({ children }: { children: React.ReactNode }) { extractedOverId: overItemId, }); - if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) { + if ( + oldIndex !== -1 && + newIndex !== -1 && + oldIndex !== newIndex + ) { // Optimistically update the UI const newItems = [...items]; const [movedItem] = newItems.splice(oldIndex, 1); @@ -636,24 +694,31 @@ function DndWrapper({ children }: { children: React.ReactNode }) { {activeItem.name} {/* Show count badge if dragging multiple files */} - {activeItem.selectedFiles && activeItem.selectedFiles.length > 1 && ( -
- {activeItem.selectedFiles.length} -
- )} + {activeItem.selectedFiles && + activeItem.selectedFiles.length > 1 && ( +
+ {activeItem.selectedFiles.length} +
+ )} ) : ( // Column/List view preview
- - {activeItem.name} + + + {activeItem.name} + {/* Show count badge if dragging multiple files */} - {activeItem.selectedFiles && activeItem.selectedFiles.length > 1 && ( -
- {activeItem.selectedFiles.length} -
- )} + {activeItem.selectedFiles && + activeItem.selectedFiles.length > 1 && ( +
+ {activeItem.selectedFiles.length} +
+ )}
) ) : null} diff --git a/packages/interface/src/components/Explorer/views/ColumnView/Column.tsx b/packages/interface/src/components/Explorer/views/ColumnView/Column.tsx index 35e01fa34..22c0fea5c 100644 --- a/packages/interface/src/components/Explorer/views/ColumnView/Column.tsx +++ b/packages/interface/src/components/Explorer/views/ColumnView/Column.tsx @@ -1,207 +1,289 @@ -import { useRef, useMemo } from "react"; -import { useVirtualizer } from "@tanstack/react-virtual"; +import { useRef, memo, useCallback } from "react"; +import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"; import clsx from "clsx"; import type { File, SdPath } from "@sd/ts-client"; import { useNormalizedQuery } from "../../../../context"; import { ColumnItem } from "./ColumnItem"; import { useExplorer } from "../../context"; -import { useSelection } from "../../SelectionContext"; import { useContextMenu } from "../../../../hooks/useContextMenu"; import { Copy, Trash, Eye, FolderOpen } from "@phosphor-icons/react"; import { useLibraryMutation } from "../../../../context"; +/** + * Memoized wrapper for ColumnItem to prevent re-renders when selection changes elsewhere. + * Only re-renders when this specific item's `selected` state changes. + */ +const ColumnItemWrapper = memo( + function ColumnItemWrapper({ + file, + files, + virtualRow, + selected, + onSelectFile, + contextMenu, + }: { + file: File; + files: File[]; + virtualRow: VirtualItem; + selected: boolean; + onSelectFile: ( + file: File, + files: File[], + multi?: boolean, + range?: boolean, + ) => void; + contextMenu: ReturnType; + }) { + const handleClick = useCallback( + (multi: boolean, range: boolean) => { + onSelectFile(file, files, multi, range); + }, + [file, files, onSelectFile], + ); + + const handleContextMenu = useCallback( + async (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (!selected) { + onSelectFile(file, files, false, false); + } + await contextMenu.show(e); + }, + [file, files, selected, onSelectFile, contextMenu], + ); + + return ( +
+ +
+ ); + }, + (prev, next) => { + // Only re-render if selection state or file changed + if (prev.selected !== next.selected) return false; + if (prev.file !== next.file) return false; + if (prev.virtualRow.start !== next.virtualRow.start) return false; + if (prev.virtualRow.size !== next.virtualRow.size) return false; + // Ignore: files array, onSelectFile, contextMenu (passed through to handlers) + return true; + }, +); + interface ColumnProps { - path: SdPath; - selectedFiles: File[]; - onSelectFile: (file: File, files: File[], multi?: boolean, range?: boolean) => void; - onNavigate: (path: SdPath) => void; - nextColumnPath?: SdPath; - columnIndex: number; - isActive: boolean; + path: SdPath; + isSelected: (fileId: string) => boolean; + selectedFileIds: Set; + onSelectFile: ( + file: File, + files: File[], + multi?: boolean, + range?: boolean, + ) => void; + onNavigate: (path: SdPath) => void; + nextColumnPath?: SdPath; + columnIndex: number; + isActive: boolean; } -export function Column({ path, selectedFiles, onSelectFile, onNavigate, nextColumnPath, columnIndex, isActive }: ColumnProps) { - const parentRef = useRef(null); - const { viewSettings, sortBy } = useExplorer(); - const copyFiles = useLibraryMutation("files.copy"); - const deleteFiles = useLibraryMutation("files.delete"); +export const Column = memo(function Column({ + path, + isSelected, + selectedFileIds, + onSelectFile, + onNavigate, + nextColumnPath, + columnIndex, + isActive, +}: ColumnProps) { + const parentRef = useRef(null); + const { viewSettings, sortBy } = useExplorer(); + const copyFiles = useLibraryMutation("files.copy"); + const deleteFiles = useLibraryMutation("files.delete"); - const directoryQuery = useNormalizedQuery({ - wireMethod: "query:files.directory_listing", - input: { - path: path, - limit: null, - include_hidden: false, - sort_by: sortBy as any, - folders_first: viewSettings.foldersFirst, - }, - resourceType: "file", - pathScope: path, - // includeDescendants defaults to false for exact directory matching - }); + const directoryQuery = useNormalizedQuery({ + wireMethod: "query:files.directory_listing", + input: { + path: path, + limit: null, + include_hidden: false, + sort_by: sortBy as any, + folders_first: viewSettings.foldersFirst, + }, + resourceType: "file", + pathScope: path, + // includeDescendants defaults to false for exact directory matching + }); - const files = directoryQuery.data?.files || []; + const files = directoryQuery.data?.files || []; - const rowVirtualizer = useVirtualizer({ - count: files.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 32, - overscan: 10, - }); + const rowVirtualizer = useVirtualizer({ + count: files.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 32, + overscan: 10, + }); - const contextMenu = useContextMenu({ - items: [ - { - icon: Eye, - label: "Quick Look", - onClick: () => { - console.log("Quick Look"); - }, - keybind: "Space", - }, - { - icon: FolderOpen, - label: "Open", - onClick: (file: File) => { - if (file.kind === "Directory") { - onNavigate(file.sd_path); - } - }, - keybind: "⌘O", - }, - { type: "separator" }, - { - icon: Copy, - label: "Copy", - onClick: async (file: File) => { - window.__SPACEDRIVE__ = window.__SPACEDRIVE__ || {}; - window.__SPACEDRIVE__.clipboard = { - operation: 'copy', - files: [file.sd_path], - sourcePath: path, - }; - }, - keybind: "⌘C", - }, - { - icon: Copy, - label: "Paste", - onClick: async () => { - const clipboard = window.__SPACEDRIVE__?.clipboard; - if (!clipboard || !clipboard.files) return; + const contextMenu = useContextMenu({ + items: [ + { + icon: Eye, + label: "Quick Look", + onClick: () => { + console.log("Quick Look"); + }, + keybind: "Space", + }, + { + icon: FolderOpen, + label: "Open", + onClick: (file: File) => { + if (file.kind === "Directory") { + onNavigate(file.sd_path); + } + }, + keybind: "⌘O", + }, + { type: "separator" }, + { + icon: Copy, + label: "Copy", + onClick: async (file: File) => { + window.__SPACEDRIVE__ = window.__SPACEDRIVE__ || {}; + window.__SPACEDRIVE__.clipboard = { + operation: "copy", + files: [file.sd_path], + sourcePath: path, + }; + }, + keybind: "⌘C", + }, + { + icon: Copy, + label: "Paste", + onClick: async () => { + const clipboard = window.__SPACEDRIVE__?.clipboard; + if (!clipboard || !clipboard.files) return; - try { - await copyFiles.mutateAsync({ - sources: { paths: clipboard.files }, - destination: path, - overwrite: false, - verify_checksum: false, - preserve_timestamps: true, - move_files: false, - copy_method: "Auto" as const, - }); - } catch (err) { - console.error("Failed to paste:", err); - } - }, - keybind: "⌘V", - condition: () => { - const clipboard = window.__SPACEDRIVE__?.clipboard; - return !!clipboard && !!clipboard.files && clipboard.files.length > 0; - }, - }, - { type: "separator" }, - { - icon: Trash, - label: "Delete", - onClick: async (file: File) => { - if (confirm(`Delete "${file.name}"?`)) { - try { - await deleteFiles.mutateAsync({ - targets: { paths: [file.sd_path] }, - permanent: false, - recursive: true, - }); - } catch (err) { - console.error("Failed to delete:", err); - } - } - }, - keybind: "⌘⌫", - variant: "danger" as const, - }, - ], - }); + try { + await copyFiles.mutateAsync({ + sources: { paths: clipboard.files }, + destination: path, + overwrite: false, + verify_checksum: false, + preserve_timestamps: true, + move_files: false, + copy_method: "Auto" as const, + }); + } catch (err) { + console.error("Failed to paste:", err); + } + }, + keybind: "⌘V", + condition: () => { + const clipboard = window.__SPACEDRIVE__?.clipboard; + return ( + !!clipboard && + !!clipboard.files && + clipboard.files.length > 0 + ); + }, + }, + { type: "separator" }, + { + icon: Trash, + label: "Delete", + onClick: async (file: File) => { + if (confirm(`Delete "${file.name}"?`)) { + try { + await deleteFiles.mutateAsync({ + targets: { paths: [file.sd_path] }, + permanent: false, + recursive: true, + }); + } catch (err) { + console.error("Failed to delete:", err); + } + } + }, + keybind: "⌘⌫", + variant: "danger" as const, + }, + ], + }); - if (directoryQuery.isLoading) { - return ( -
-
Loading...
-
- ); - } + if (directoryQuery.isLoading) { + return ( +
+
Loading...
+
+ ); + } - return ( -
-
- {rowVirtualizer.getVirtualItems().map((virtualRow) => { - const file = files[virtualRow.index]; + return ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const file = files[virtualRow.index]; - // Check if this file is selected - const fileIsSelected = selectedFiles.some((f) => f.id === file.id); + // Check if this file is selected using O(1) lookup + const fileIsSelected = isSelected(file.id); - // Check if this file is part of the navigation path - const isInPath = nextColumnPath && file.sd_path.Physical && nextColumnPath.Physical - ? file.sd_path.Physical.path === nextColumnPath.Physical.path && - file.sd_path.Physical.device_slug === nextColumnPath.Physical.device_slug - : false; + // Check if this file is part of the navigation path + const isInPath = + nextColumnPath && + file.sd_path.Physical && + nextColumnPath.Physical + ? file.sd_path.Physical.path === + nextColumnPath.Physical.path && + file.sd_path.Physical.device_slug === + nextColumnPath.Physical.device_slug + : false; - return ( -
- onSelectFile(file, files, multi, range)} - onContextMenu={async (e) => { - e.preventDefault(); - e.stopPropagation(); - if (!fileIsSelected) { - onSelectFile(file, files, false, false); - } - await contextMenu.show(e); - }} - /> -
- ); - })} -
-
- ); -} + return ( + + ); + })} +
+
+ ); +}); diff --git a/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx b/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx index aa2a111cc..f53738032 100644 --- a/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx +++ b/packages/interface/src/components/Explorer/views/ColumnView/ColumnItem.tsx @@ -1,86 +1,100 @@ +import { memo, useCallback } from "react"; 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: (multi: boolean, range: boolean) => void; - onDoubleClick?: () => void; - onContextMenu?: (e: React.MouseEvent) => void; + file: File; + selected: boolean; + focused: boolean; + onClick: (multi: boolean, range: boolean) => void; + onDoubleClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; } -export function ColumnItem({ - file, - selected, - focused, - onClick, - onDoubleClick, - onContextMenu, -}: ColumnItemProps) { - const handleClick = (e: React.MouseEvent) => { - const multi = e.metaKey || e.ctrlKey; - const range = e.shiftKey; - onClick(multi, range); - }; +export const ColumnItem = memo( + function ColumnItem({ + file, + selected, + focused, + onClick, + onDoubleClick, + onContextMenu, + }: ColumnItemProps) { + const handleClick = useCallback( + (e: React.MouseEvent) => { + const multi = e.metaKey || e.ctrlKey; + const range = e.shiftKey; + onClick(multi, range); + }, + [onClick], + ); - const handleDoubleClick = () => { - if (onDoubleClick) { - onDoubleClick(); - } - }; + const handleDoubleClick = useCallback(() => { + if (onDoubleClick) { + onDoubleClick(); + } + }, [onDoubleClick]); - const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ - id: file.id, - data: { - type: "explorer-file", - sdPath: file.sd_path, - name: file.name, - file: file, - }, - }); + 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" && ( - - - - )} -
-
- ); -} + return ( +
+ +
+ +
+ {file.name} + {file.kind === "Directory" && ( + + + + )} +
+
+ ); + }, + (prev, next) => { + // Only re-render if selection state, focus, or file changed + if (prev.selected !== next.selected) return false; + if (prev.focused !== next.focused) return false; + if (prev.file !== next.file) return false; + // Ignore onClick, onDoubleClick, onContextMenu function reference changes + return true; + }, +); diff --git a/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx b/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx index 56678b9c5..e6430e45a 100644 --- a/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx +++ b/packages/interface/src/components/Explorer/views/ColumnView/ColumnView.tsx @@ -7,213 +7,286 @@ import type { DirectorySortBy } from "@sd/ts-client"; import { Column } from "./Column"; export function ColumnView() { - const { currentPath, setCurrentPath, sortBy, viewSettings } = useExplorer(); - const { selectedFiles, selectFile, clearSelection } = useSelection(); - const [columnStack, setColumnStack] = useState([]); - const isInternalNavigationRef = useRef(false); + const { currentPath, setCurrentPath, sortBy, viewSettings } = useExplorer(); + const { + selectedFiles, + selectedFileIds, + isSelected, + selectFile, + clearSelection, + } = useSelection(); + const [columnStack, setColumnStack] = useState([]); + const isInternalNavigationRef = useRef(false); - // Initialize column stack when currentPath changes externally - useEffect(() => { - // Only reset if this is an external navigation (not from within column view) - if (currentPath && !isInternalNavigationRef.current) { - setColumnStack([currentPath]); - clearSelection(); - } - isInternalNavigationRef.current = false; - }, [currentPath, clearSelection]); + // Initialize column stack when currentPath changes externally + useEffect(() => { + // Only reset if this is an external navigation (not from within column view) + if (currentPath && !isInternalNavigationRef.current) { + setColumnStack([currentPath]); + clearSelection(); + } + isInternalNavigationRef.current = false; + }, [currentPath, clearSelection]); - // 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); + // 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); - // 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]); + // Only update columns for single selection (not multi/range) + 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 (this is navigation) + isInternalNavigationRef.current = true; + setCurrentPath(file.sd_path); + } else { + // For files, just truncate columns after current + // DON'T call setCurrentPath - it causes ExplorerLayout to re-render + setColumnStack((prev) => prev.slice(0, columnIndex + 1)); + } + } + }, + [selectFile, setCurrentPath], + ); - const handleNavigate = useCallback((path: SdPath) => { - setCurrentPath(path); - }, [setCurrentPath]); + const handleNavigate = useCallback( + (path: SdPath) => { + setCurrentPath(path); + }, + [setCurrentPath], + ); - // Find the active column (the one containing the first selected file) - const activeColumnIndex = useMemo(() => { - if (selectedFiles.length === 0) return columnStack.length - 1; // Default to last column + // Find the active column (the one containing the first selected file) + const activeColumnIndex = useMemo(() => { + if (selectedFiles.length === 0) return columnStack.length - 1; // Default to last column - const firstSelected = selectedFiles[0]; - const filePath = firstSelected.sd_path.Physical?.path; - if (!filePath) return columnStack.length - 1; + const firstSelected = selectedFiles[0]; + const filePath = firstSelected.sd_path.Physical?.path; + if (!filePath) return columnStack.length - 1; - const fileParent = filePath.substring(0, filePath.lastIndexOf('/')); + const fileParent = filePath.substring(0, filePath.lastIndexOf("/")); - return columnStack.findIndex((path) => { - const columnPath = path.Physical?.path; - return columnPath === fileParent; - }); - }, [selectedFiles, columnStack]); + return columnStack.findIndex((path) => { + const columnPath = path.Physical?.path; + return columnPath === fileParent; + }); + }, [selectedFiles, columnStack]); - const activeColumnPath = columnStack[activeColumnIndex]; + const activeColumnPath = columnStack[activeColumnIndex]; - // Query files for the active column (for keyboard navigation) - const activeColumnQuery = useNormalizedQuery({ - wireMethod: "query:files.directory_listing", - input: activeColumnPath - ? { - path: activeColumnPath, - limit: null, - include_hidden: false, - sort_by: sortBy as DirectorySortBy, - folders_first: viewSettings.foldersFirst, - } - : null!, - resourceType: "file", - enabled: !!activeColumnPath, - pathScope: activeColumnPath, - }); + // Query files for the active column (for keyboard navigation) + const activeColumnQuery = useNormalizedQuery({ + wireMethod: "query:files.directory_listing", + input: activeColumnPath + ? { + path: activeColumnPath, + limit: null, + include_hidden: false, + sort_by: sortBy as DirectorySortBy, + folders_first: viewSettings.foldersFirst, + } + : null!, + resourceType: "file", + enabled: !!activeColumnPath, + pathScope: activeColumnPath, + }); - const activeColumnFiles = activeColumnQuery.data?.files || []; + const activeColumnFiles = activeColumnQuery.data?.files || []; - // Query the next column for right arrow navigation - const nextColumnPath = columnStack[activeColumnIndex + 1]; - const nextColumnQuery = useNormalizedQuery({ - wireMethod: "query:files.directory_listing", - input: nextColumnPath - ? { - path: nextColumnPath, - limit: null, - include_hidden: false, - sort_by: sortBy as DirectorySortBy, - folders_first: viewSettings.foldersFirst, - } - : null!, - resourceType: "file", - enabled: !!nextColumnPath, - pathScope: nextColumnPath, - }); + // Query the next column for right arrow navigation + const nextColumnPath = columnStack[activeColumnIndex + 1]; + const nextColumnQuery = useNormalizedQuery({ + wireMethod: "query:files.directory_listing", + input: nextColumnPath + ? { + path: nextColumnPath, + limit: null, + include_hidden: false, + sort_by: sortBy as DirectorySortBy, + folders_first: viewSettings.foldersFirst, + } + : null!, + resourceType: "file", + enabled: !!nextColumnPath, + pathScope: nextColumnPath, + }); - const nextColumnFiles = nextColumnQuery.data?.files || []; + const nextColumnFiles = nextColumnQuery.data?.files || []; - // Keyboard navigation - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) { - return; - } + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ( + !["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes( + e.key, + ) + ) { + return; + } - e.preventDefault(); + e.preventDefault(); - if (e.key === "ArrowUp" || e.key === "ArrowDown") { - // Navigate within current column - if (activeColumnFiles.length === 0) return; + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + // Navigate within current column + if (activeColumnFiles.length === 0) return; - const currentIndex = selectedFiles.length > 0 - ? activeColumnFiles.findIndex((f) => f.id === selectedFiles[0].id) - : -1; + const currentIndex = + selectedFiles.length > 0 + ? activeColumnFiles.findIndex( + (f) => f.id === selectedFiles[0].id, + ) + : -1; - const newIndex = e.key === "ArrowDown" - ? currentIndex < 0 ? 0 : Math.min(currentIndex + 1, activeColumnFiles.length - 1) - : currentIndex < 0 ? 0 : Math.max(currentIndex - 1, 0); + const newIndex = + e.key === "ArrowDown" + ? currentIndex < 0 + ? 0 + : Math.min( + currentIndex + 1, + activeColumnFiles.length - 1, + ) + : currentIndex < 0 + ? 0 + : Math.max(currentIndex - 1, 0); - if (newIndex !== currentIndex && activeColumnFiles[newIndex]) { - const newFile = activeColumnFiles[newIndex]; - handleSelectFile(newFile, activeColumnIndex, activeColumnFiles); + if (newIndex !== currentIndex && activeColumnFiles[newIndex]) { + 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)); - 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 - const firstSelected = selectedFiles[0]; - if (firstSelected?.kind === "Directory" && activeColumnIndex < columnStack.length - 1) { - // Select first item in next column - if (nextColumnFiles.length > 0) { - const firstFile = nextColumnFiles[0]; - handleSelectFile(firstFile, activeColumnIndex + 1, nextColumnFiles); + // 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)); + 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 + const firstSelected = selectedFiles[0]; + if ( + firstSelected?.kind === "Directory" && + activeColumnIndex < columnStack.length - 1 + ) { + // Select first item in next column + if (nextColumnFiles.length > 0) { + 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); - } - } - } - }; + // Scroll to keep selection visible + setTimeout(() => { + const element = document.querySelector( + `[data-file-id="${firstFile.id}"]`, + ); + if (element) { + element.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, 0); + } + } + } + }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [activeColumnFiles, nextColumnFiles, selectedFiles, activeColumnIndex, columnStack, handleSelectFile]); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + activeColumnFiles, + nextColumnFiles, + selectedFiles, + activeColumnIndex, + columnStack, + handleSelectFile, + ]); - if (!currentPath) { - return ( -
-
No location selected
-
- ); - } + if (!currentPath) { + return ( +
+
No location selected
+
+ ); + } - return ( -
- {columnStack.map((path, index) => { - // 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 + // Compute which columns are active based on selection + // This is stable unless selection changes + const activeColumnPaths = useMemo(() => { + if (selectedFiles.length === 0) return new Set(); - return ( - handleSelectFile(file, index, files, multi, range)} - onNavigate={handleNavigate} - nextColumnPath={columnStack[index + 1]} - columnIndex={index} - isActive={isActive} - /> - ); - })} -
- ); + const paths = new Set(); + for (const file of selectedFiles) { + const filePath = file.sd_path.Physical?.path; + if (!filePath) continue; + const fileParent = filePath.substring(0, filePath.lastIndexOf("/")); + paths.add(fileParent); + } + return paths; + }, [selectedFiles]); + + return ( +
+ {columnStack.map((path, index) => { + const columnPath = 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 + ? activeColumnPaths.has(columnPath) + : index === columnStack.length - 1; + + return ( + + handleSelectFile(file, index, files, multi, range) + } + onNavigate={handleNavigate} + nextColumnPath={columnStack[index + 1]} + columnIndex={index} + isActive={isActive} + /> + ); + })} +
+ ); } diff --git a/packages/interface/src/components/Explorer/views/ListView/ListView.tsx b/packages/interface/src/components/Explorer/views/ListView/ListView.tsx index ec10c54dc..6a8abb914 100644 --- a/packages/interface/src/components/Explorer/views/ListView/ListView.tsx +++ b/packages/interface/src/components/Explorer/views/ListView/ListView.tsx @@ -11,258 +11,282 @@ import { useSelection } from "../../SelectionContext"; import { useNormalizedQuery } from "../../../../context"; import { TableRow } from "./TableRow"; import { - useTable, - ROW_HEIGHT, - TABLE_PADDING_X, - TABLE_PADDING_Y, - TABLE_HEADER_HEIGHT, + useTable, + ROW_HEIGHT, + TABLE_PADDING_X, + TABLE_PADDING_Y, + TABLE_HEADER_HEIGHT, } from "./useTable"; export const ListView = memo(function ListView() { - const { currentPath, sortBy, setSortBy, viewSettings, setCurrentFiles } = useExplorer(); - const { focusedIndex, setFocusedIndex, selectedFiles, selectFile, moveFocus } = useSelection(); + const { currentPath, sortBy, setSortBy, viewSettings, setCurrentFiles } = + useExplorer(); + const { + focusedIndex, + setFocusedIndex, + selectedFiles, + selectedFileIds, + isSelected, + selectFile, + moveFocus, + } = useSelection(); - const containerRef = useRef(null); - const headerScrollRef = useRef(null); - const bodyScrollRef = useRef(null); + const containerRef = useRef(null); + const headerScrollRef = useRef(null); + const bodyScrollRef = useRef(null); - // Memoize query input to prevent unnecessary re-fetches - const queryInput = useMemo( - () => - currentPath - ? { - path: currentPath, - limit: null, - include_hidden: false, - sort_by: sortBy as DirectorySortBy, - folders_first: viewSettings.foldersFirst, - } - : null!, - [currentPath, sortBy, viewSettings.foldersFirst] - ); + // Memoize query input to prevent unnecessary re-fetches + const queryInput = useMemo( + () => + currentPath + ? { + path: currentPath, + limit: null, + include_hidden: false, + sort_by: sortBy as DirectorySortBy, + folders_first: viewSettings.foldersFirst, + } + : null!, + [currentPath, sortBy, viewSettings.foldersFirst], + ); - const pathScope = useMemo(() => currentPath ?? undefined, [currentPath]); + const pathScope = useMemo(() => currentPath ?? undefined, [currentPath]); - const directoryQuery = useNormalizedQuery({ - wireMethod: "query:files.directory_listing", - input: queryInput, - resourceType: "file", - enabled: !!currentPath, - pathScope, - }); + const directoryQuery = useNormalizedQuery({ + wireMethod: "query:files.directory_listing", + input: queryInput, + resourceType: "file", + enabled: !!currentPath, + pathScope, + }); - const files = directoryQuery.data?.files || []; - const { table } = useTable(files); - const { rows } = table.getRowModel(); + const files = directoryQuery.data?.files || []; + const { table } = useTable(files); + const { rows } = table.getRowModel(); - // Update current files in explorer context for quick preview navigation - useEffect(() => { - setCurrentFiles(files); - }, [files, setCurrentFiles]); + // Update current files in explorer context for quick preview navigation + useEffect(() => { + setCurrentFiles(files); + }, [files, setCurrentFiles]); - // Virtual row rendering - uses the container as scroll element - const rowVirtualizer = useVirtualizer({ - count: rows.length, - getScrollElement: useCallback(() => containerRef.current, []), - estimateSize: useCallback(() => ROW_HEIGHT, []), - paddingStart: TABLE_HEADER_HEIGHT + TABLE_PADDING_Y, - paddingEnd: TABLE_PADDING_Y, - overscan: 15, - }); + // Virtual row rendering - uses the container as scroll element + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: useCallback(() => containerRef.current, []), + estimateSize: useCallback(() => ROW_HEIGHT, []), + paddingStart: TABLE_HEADER_HEIGHT + TABLE_PADDING_Y, + paddingEnd: TABLE_PADDING_Y, + overscan: 15, + }); - const virtualRows = rowVirtualizer.getVirtualItems(); + const virtualRows = rowVirtualizer.getVirtualItems(); - // Sync horizontal scroll between header and body - const handleBodyScroll = useCallback(() => { - if (bodyScrollRef.current && headerScrollRef.current) { - headerScrollRef.current.scrollLeft = bodyScrollRef.current.scrollLeft; - } - }, []); + // Sync horizontal scroll between header and body + const handleBodyScroll = useCallback(() => { + if (bodyScrollRef.current && headerScrollRef.current) { + headerScrollRef.current.scrollLeft = + bodyScrollRef.current.scrollLeft; + } + }, []); - // Store values in refs to avoid effect re-runs - const rowVirtualizerRef = useRef(rowVirtualizer); - rowVirtualizerRef.current = rowVirtualizer; - const filesRef = useRef(files); - filesRef.current = files; + // Store values in refs to avoid effect re-runs + const rowVirtualizerRef = useRef(rowVirtualizer); + rowVirtualizerRef.current = rowVirtualizer; + const filesRef = useRef(files); + filesRef.current = files; - // Keyboard navigation - stable effect, uses refs for changing values - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault(); - const direction = e.key === "ArrowDown" ? "down" : "up"; - const currentFiles = filesRef.current; + // Keyboard navigation - stable effect, uses refs for changing values + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const direction = e.key === "ArrowDown" ? "down" : "up"; + const currentFiles = filesRef.current; - const currentIndex = focusedIndex >= 0 ? focusedIndex : 0; - const newIndex = - direction === "down" - ? Math.min(currentIndex + 1, currentFiles.length - 1) - : Math.max(currentIndex - 1, 0); + const currentIndex = focusedIndex >= 0 ? focusedIndex : 0; + const newIndex = + direction === "down" + ? Math.min(currentIndex + 1, currentFiles.length - 1) + : Math.max(currentIndex - 1, 0); - if (e.shiftKey) { - // Range selection with shift - if (newIndex !== focusedIndex && currentFiles[newIndex]) { - selectFile(currentFiles[newIndex], currentFiles, false, true); - setFocusedIndex(newIndex); - } - } else { - moveFocus(direction, currentFiles); - } + if (e.shiftKey) { + // Range selection with shift + if (newIndex !== focusedIndex && currentFiles[newIndex]) { + selectFile( + currentFiles[newIndex], + currentFiles, + false, + true, + ); + setFocusedIndex(newIndex); + } + } else { + moveFocus(direction, currentFiles); + } - // Scroll to keep selection visible - rowVirtualizerRef.current.scrollToIndex(newIndex, { align: "auto" }); - } - }; + // Scroll to keep selection visible + rowVirtualizerRef.current.scrollToIndex(newIndex, { + align: "auto", + }); + } + }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [focusedIndex, selectFile, setFocusedIndex, moveFocus]); + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [focusedIndex, selectFile, setFocusedIndex, moveFocus]); - // Column sorting handler - const handleHeaderClick = useCallback( - (columnId: string) => { - const sortMap: Record = { - name: "name", - size: "size", - modified: "modified", - type: "type", - }; - const newSort = sortMap[columnId]; - if (newSort) { - setSortBy(newSort); - } - }, - [setSortBy] - ); + // Column sorting handler + const handleHeaderClick = useCallback( + (columnId: string) => { + const sortMap: Record = { + name: "name", + size: "size", + modified: "modified", + type: "type", + }; + const newSort = sortMap[columnId]; + if (newSort) { + setSortBy(newSort); + } + }, + [setSortBy], + ); - // Calculate total width for table - const headerGroups = table.getHeaderGroups(); - const totalWidth = table.getTotalSize() + TABLE_PADDING_X * 2; + // Calculate total width for table + const headerGroups = table.getHeaderGroups(); + const totalWidth = table.getTotalSize() + TABLE_PADDING_X * 2; - return ( -
- {/* Sticky Header */} -
-
-
- {headerGroups.map((headerGroup) => - headerGroup.headers.map((header) => { - const isSorted = sortBy === header.id; - const canResize = header.column.getCanResize(); + return ( +
+ {/* Sticky Header */} +
+
+
+ {headerGroups.map((headerGroup) => + headerGroup.headers.map((header) => { + const isSorted = sortBy === header.id; + const canResize = header.column.getCanResize(); - return ( -
handleHeaderClick(header.id)} - > - - {flexRender(header.column.columnDef.header, header.getContext())} - + return ( +
+ handleHeaderClick(header.id) + } + > + + {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + - {isSorted && ( - - )} + {isSorted && ( + + )} - {/* Resize handle */} - {canResize && ( -
e.stopPropagation()} - className={clsx( - "absolute right-0 top-1/2 h-4 w-1 -translate-y-1/2 cursor-col-resize rounded-full", - header.column.getIsResizing() - ? "bg-accent" - : "bg-transparent hover:bg-ink-faint/50" - )} - /> - )} -
- ); - }) - )} -
-
-
+ {/* Resize handle */} + {canResize && ( +
+ e.stopPropagation() + } + className={clsx( + "absolute right-0 top-1/2 h-4 w-1 -translate-y-1/2 cursor-col-resize rounded-full", + header.column.getIsResizing() + ? "bg-accent" + : "bg-transparent hover:bg-ink-faint/50", + )} + /> + )} +
+ ); + }), + )} +
+
+
- {/* Virtual List Body */} -
-
-
- {virtualRows.map((virtualRow) => { - const row = rows[virtualRow.index]; - if (!row) return null; + {/* Virtual List Body */} +
+
+
+ {virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index]; + if (!row) return null; - const file = row.original; - const isSelected = selectedFiles.some((f) => f.id === file.id); - const isFocused = focusedIndex === virtualRow.index; - const previousRow = rows[virtualRow.index - 1]; - const nextRow = rows[virtualRow.index + 1]; - const isPreviousSelected = previousRow - ? selectedFiles.some((f) => f.id === previousRow.original.id) - : false; - const isNextSelected = nextRow - ? selectedFiles.some((f) => f.id === nextRow.original.id) - : false; + const file = row.original; + // Use O(1) lookup instead of O(n) selectedFiles.some() + const fileIsSelected = isSelected(file.id); + const isFocused = focusedIndex === virtualRow.index; + const previousRow = rows[virtualRow.index - 1]; + const nextRow = rows[virtualRow.index + 1]; + // Use O(1) Set lookup for adjacent selection detection + const isPreviousSelected = previousRow + ? selectedFileIds.has(previousRow.original.id) + : false; + const isNextSelected = nextRow + ? selectedFileIds.has(nextRow.original.id) + : false; - return ( - - ); - })} -
-
-
-
- ); + return ( + + ); + })} +
+
+
+
+ ); }); diff --git a/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx b/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx index ce228b3b1..f60b16eff 100644 --- a/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx +++ b/packages/interface/src/components/Explorer/views/ListView/TableRow.tsx @@ -6,156 +6,181 @@ import type { File } from "@sd/ts-client"; import { File as FileComponent } from "../../File"; import { useExplorer } from "../../context"; -import { useSelection } from "../../SelectionContext"; import { TagPill } from "../../../Tags"; import { ROW_HEIGHT, TABLE_PADDING_X } from "./useTable"; interface TableRowProps { - row: Row; - file: File; - files: File[]; - index: number; - isSelected: boolean; - isFocused: boolean; - isPreviousSelected: boolean; - isNextSelected: boolean; - measureRef: (node: HTMLElement | null) => void; + row: Row; + file: File; + files: File[]; + index: number; + isSelected: boolean; + isFocused: boolean; + isPreviousSelected: boolean; + isNextSelected: boolean; + measureRef: (node: HTMLElement | null) => void; + selectFile: ( + file: File, + files: File[], + multi?: boolean, + range?: boolean, + ) => void; } -export const TableRow = memo(function TableRow({ - row, - file, - files, - index, - isSelected, - isFocused, - isPreviousSelected, - isNextSelected, - measureRef, -}: TableRowProps) { - const { setCurrentPath } = useExplorer(); - const { selectFile } = useSelection(); +export const TableRow = memo( + function TableRow({ + row, + file, + files, + index, + isSelected, + isFocused, + isPreviousSelected, + isNextSelected, + measureRef, + selectFile, + }: TableRowProps) { + const { setCurrentPath } = useExplorer(); - const handleClick = useCallback( - (e: React.MouseEvent) => { - const multi = e.metaKey || e.ctrlKey; - const range = e.shiftKey; - selectFile(file, files, multi, range); - }, - [file, files, selectFile] - ); + const handleClick = useCallback( + (e: React.MouseEvent) => { + const multi = e.metaKey || e.ctrlKey; + const range = e.shiftKey; + selectFile(file, files, multi, range); + }, + [file, files, selectFile], + ); - const handleDoubleClick = useCallback(() => { - if (file.kind === "Directory") { - setCurrentPath(file.sd_path); - } - }, [file, setCurrentPath]); + const handleDoubleClick = useCallback(() => { + if (file.kind === "Directory") { + setCurrentPath(file.sd_path); + } + }, [file, setCurrentPath]); - const cells = row.getVisibleCells(); + const cells = row.getVisibleCells(); - return ( -
- {/* Background layer for alternating colors and selection */} -
- {/* Subtle separator between connected selected rows */} - {isSelected && isPreviousSelected && ( -
- )} -
+ return ( +
+ {/* Background layer for alternating colors and selection */} +
+ {/* Subtle separator between connected selected rows */} + {isSelected && isPreviousSelected && ( +
+ )} +
- {/* Row content */} -
- {cells.map((cell) => { - const isNameColumn = cell.column.id === "name"; + {/* Row content */} +
+ {cells.map((cell) => { + const isNameColumn = cell.column.id === "name"; - return ( -
- {isNameColumn ? ( - - ) : ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - )} -
- ); - })} -
-
- ); -}); + return ( +
+ {isNameColumn ? ( + + ) : ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + )} +
+ ); + })} +
+
+ ); + }, + (prev, next) => { + // Only re-render if these specific props changed + if (prev.isSelected !== next.isSelected) return false; + if (prev.isFocused !== next.isFocused) return false; + if (prev.isPreviousSelected !== next.isPreviousSelected) return false; + if (prev.isNextSelected !== next.isNextSelected) return false; + if (prev.file !== next.file) return false; + if (prev.index !== next.index) return false; + // Ignore: row, files, measureRef, selectFile (function references) + return true; + }, +); // Name cell with icon and tags const NameCell = memo(function NameCell({ file }: { file: File }) { - return ( -
- {/* File icon */} -
- -
+ return ( +
+ {/* File icon */} +
+ +
- {/* File name */} - - {file.name} - + {/* File name */} + {file.name} - {/* Tags (inline, compact) */} - {file.tags && file.tags.length > 0 && ( -
- {file.tags.slice(0, 2).map((tag) => ( - - {tag.canonical_name} - - ))} - {file.tags.length > 2 && ( - - +{file.tags.length - 2} - - )} -
- )} -
- ); + {/* Tags (inline, compact) */} + {file.tags && file.tags.length > 0 && ( +
+ {file.tags.slice(0, 2).map((tag) => ( + + {tag.canonical_name} + + ))} + {file.tags.length > 2 && ( + + +{file.tags.length - 2} + + )} +
+ )} +
+ ); }); diff --git a/packages/ts-client/src/hooks/useNormalizedQuery.ts b/packages/ts-client/src/hooks/useNormalizedQuery.ts index d54407c63..1c280cd2f 100644 --- a/packages/ts-client/src/hooks/useNormalizedQuery.ts +++ b/packages/ts-client/src/hooks/useNormalizedQuery.ts @@ -191,10 +191,10 @@ export function useNormalizedQuery( JSON.stringify(optionsRef.current.pathScope) !== JSON.stringify(capturedPathScope) ) { - console.log("[useNormalizedQuery] Dropping stale event", { - eventPathScope: capturedPathScope, - currentPathScope: optionsRef.current.pathScope, - }); + // console.log("[useNormalizedQuery] Dropping stale event", { + // eventPathScope: capturedPathScope, + // currentPathScope: optionsRef.current.pathScope, + // }); return; } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 5a00b11e9..1b6aee80a 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -368,52 +368,78 @@ fn build_mobile() -> Result<()> { ); } - // iOS targets + // Check which iOS targets are installed (macOS only) #[cfg(target_os = "macos")] - let ios_targets = [ - ("aarch64-apple-ios", "Device", false), - ("aarch64-apple-ios-sim", "Simulator (arm64)", true), - ]; + { + let rust_targets = system::get_rust_targets().unwrap_or_default(); + let ios_targets = [ + ("aarch64-apple-ios", "Device", false), + ("aarch64-apple-ios-sim", "Simulator (arm64)", true), + ]; - #[cfg(target_os = "macos")] - println!("Building for iOS targets..."); - #[cfg(target_os = "macos")] - for (target, name, _is_sim) in &ios_targets { - println!(" Building for iOS {} ({})...", name, target); + let available_ios_targets: Vec<_> = ios_targets + .iter() + .filter(|(target, _, _)| rust_targets.contains(&target.to_string())) + .collect(); - let status = Command::new("cargo") - .args(["build", "--release", "--target", target]) - .current_dir(&mobile_core_dir) - .env("IPHONEOS_DEPLOYMENT_TARGET", "18.0") - .status() - .context(format!("Failed to build for {}", target))?; + if !available_ios_targets.is_empty() { + println!("Building for iOS targets..."); + for (target, name, _is_sim) in available_ios_targets { + println!(" Building for iOS {} ({})...", name, target); - if !status.success() { - anyhow::bail!("Build failed for target: {}", target); + let status = Command::new("cargo") + .args(["build", "--release", "--target", target]) + .current_dir(&mobile_core_dir) + .env("IPHONEOS_DEPLOYMENT_TARGET", "18.0") + .status() + .context(format!("Failed to build for {}", target))?; + + if !status.success() { + anyhow::bail!("Build failed for target: {}", target); + } + + println!(" ✓ {} build complete", name); + } + } else { + println!("No iOS targets installed. Skipping iOS builds."); + println!(" To add iOS support, run:"); + println!(" rustup target add aarch64-apple-ios aarch64-apple-ios-sim"); } - - println!(" ✓ {} build complete", name); } + // Check which Android targets are installed + let rust_targets = system::get_rust_targets().unwrap_or_default(); let android_targets = [ ("aarch64-linux-android", "Device", false), ("x86_64-linux-android", "Android Emulator", true), - // add more as needed ]; - println!("Building for Android targets..."); - for (target, name, _is_emulator) in &android_targets { - println!(" Building for Android {} ({}) ...", name, target); + let available_android_targets: Vec<_> = android_targets + .iter() + .filter(|(target, _, _)| rust_targets.contains(&target.to_string())) + .collect(); - let status = Command::new("cargo") - .args(["build", "--release", "--target", target]) - .current_dir(&mobile_core_dir) - .status() - .context(format!("Failed to build for {}", target))?; + if !available_android_targets.is_empty() { + println!("Building for Android targets..."); + for (target, name, _is_emulator) in available_android_targets { + println!(" Building for Android {} ({})...", name, target); - if !status.success() { - anyhow::bail!("Build failed for target: {}", target); + let status = Command::new("cargo") + .args(["build", "--release", "--target", target]) + .current_dir(&mobile_core_dir) + .status() + .context(format!("Failed to build for {}", target))?; + + if !status.success() { + anyhow::bail!("Build failed for target: {}", target); + } + + println!(" ✓ {} build complete", name); } + } else { + println!("No Android targets installed. Skipping Android builds."); + println!(" To add Android support, run:"); + println!(" rustup target add aarch64-linux-android x86_64-linux-android"); } // Copy built libraries to the iOS module directory