mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-20 07:37:26 -05:00
Update App component and Explorer views for improved performance and usability
- Refactored the App component to enhance readability and maintainability, including adjustments to route handling and client initialization. - Introduced QuickPreviewSyncer and QuickPreviewController components in the Explorer to optimize rendering and selection handling. - Enhanced Column and ListView components with memoization to prevent unnecessary re-renders, improving performance during file selection and navigation. - Updated event handling in the useNormalizedQuery hook to streamline query management and improve type safety. - Adjusted various components to ensure consistent styling and behavior across the application.
This commit is contained in:
BIN
apps/mobile/modules/sd-mobile-core/core/Cargo.lock
generated
BIN
apps/mobile/modules/sd-mobile-core/core/Cargo.lock
generated
Binary file not shown.
@@ -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<SpacedriveClient | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [route, setRoute] = useState<string>("/");
|
||||
const [client, setClient] = useState<SpacedriveClient | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [route, setRoute] = useState<string>("/");
|
||||
|
||||
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 <FloatingControls />;
|
||||
}
|
||||
// Routes that don't need the client
|
||||
if (route === "/floating-controls") {
|
||||
return <FloatingControls />;
|
||||
}
|
||||
|
||||
if (route === "/drag-overlay") {
|
||||
return <DragOverlay />;
|
||||
}
|
||||
if (route === "/drag-overlay") {
|
||||
return <DragOverlay />;
|
||||
}
|
||||
|
||||
if (route === "/contextmenu") {
|
||||
return <ContextMenuWindow />;
|
||||
}
|
||||
if (route === "/contextmenu") {
|
||||
return <ContextMenuWindow />;
|
||||
}
|
||||
|
||||
if (route === "/drag-demo") {
|
||||
return <DragDemo />;
|
||||
}
|
||||
if (route === "/drag-demo") {
|
||||
return <DragDemo />;
|
||||
}
|
||||
|
||||
if (route === "/spacedrop") {
|
||||
return <SpacedropWindow />;
|
||||
}
|
||||
if (route === "/spacedrop") {
|
||||
return <SpacedropWindow />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.log("Rendering error state");
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Error</h1>
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
console.log("Rendering error state");
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold mb-4">Error</h1>
|
||||
<p className="text-red-400">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
console.log("Rendering loading state");
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-xl">Initializing client...</div>
|
||||
<p className="text-gray-400 text-sm mt-2">Check console for logs</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!client) {
|
||||
console.log("Rendering loading state");
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-950 text-white">
|
||||
<div className="text-center">
|
||||
<div className="animate-pulse text-xl">
|
||||
Initializing client...
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm mt-2">
|
||||
Check console for logs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Rendering Interface with client");
|
||||
console.log("Rendering Interface with client");
|
||||
|
||||
// Route to different UIs based on window type
|
||||
if (route === "/settings") {
|
||||
return (
|
||||
<PlatformProvider platform={platform}>
|
||||
<SpacedriveProvider client={client}>
|
||||
<Settings />
|
||||
</SpacedriveProvider>
|
||||
</PlatformProvider>
|
||||
);
|
||||
}
|
||||
// Route to different UIs based on window type
|
||||
if (route === "/settings") {
|
||||
return (
|
||||
<PlatformProvider platform={platform}>
|
||||
<SpacedriveProvider client={client}>
|
||||
<Settings />
|
||||
</SpacedriveProvider>
|
||||
</PlatformProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (route === "/inspector") {
|
||||
return (
|
||||
<PlatformProvider platform={platform}>
|
||||
<SpacedriveProvider client={client}>
|
||||
<div className="h-screen bg-app overflow-hidden">
|
||||
<PopoutInspector />
|
||||
</div>
|
||||
</SpacedriveProvider>
|
||||
</PlatformProvider>
|
||||
);
|
||||
}
|
||||
if (route === "/inspector") {
|
||||
return (
|
||||
<PlatformProvider platform={platform}>
|
||||
<SpacedriveProvider client={client}>
|
||||
<div className="h-screen bg-app overflow-hidden">
|
||||
<PopoutInspector />
|
||||
</div>
|
||||
</SpacedriveProvider>
|
||||
</PlatformProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (route === "/cache-demo") {
|
||||
return <LocationCacheDemo />;
|
||||
}
|
||||
if (route === "/cache-demo") {
|
||||
return <LocationCacheDemo />;
|
||||
}
|
||||
|
||||
if (route === "/quick-preview") {
|
||||
return (
|
||||
<div className="h-screen bg-app overflow-hidden">
|
||||
<QuickPreview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (route === "/quick-preview") {
|
||||
return (
|
||||
<div className="h-screen bg-app overflow-hidden">
|
||||
<QuickPreview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProvider platform={platform}>
|
||||
<Explorer client={client} />
|
||||
</PlatformProvider>
|
||||
);
|
||||
return (
|
||||
<PlatformProvider platform={platform}>
|
||||
<Explorer client={client} />
|
||||
</PlatformProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -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 (
|
||||
<QuickPreviewFullscreen
|
||||
fileId={quickPreviewFileId}
|
||||
isOpen={!!quickPreviewFileId}
|
||||
onClose={closeQuickPreview}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
hasPrevious={hasPrevious}
|
||||
hasNext={hasNext}
|
||||
sidebarWidth={sidebarWidth}
|
||||
inspectorWidth={inspectorWidth}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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) */}
|
||||
<KeyboardHandler />
|
||||
|
||||
{/* Syncs selection to QuickPreview - isolated to prevent frame rerenders */}
|
||||
<QuickPreviewSyncer />
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{/* Hide inspector on Overview screen and Knowledge view (has its own) */}
|
||||
{inspectorVisible && !isOverview && !isKnowledgeView && (
|
||||
@@ -229,57 +309,15 @@ export function ExplorerLayout() {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 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 (
|
||||
<QuickPreviewFullscreen
|
||||
fileId={quickPreviewFileId}
|
||||
isOpen={!!quickPreviewFileId}
|
||||
onClose={closeQuickPreview}
|
||||
onNext={handleNext}
|
||||
onPrevious={handlePrevious}
|
||||
hasPrevious={hasPrevious}
|
||||
hasNext={hasNext}
|
||||
sidebarWidth={sidebarVisible ? 220 : 0}
|
||||
inspectorWidth={
|
||||
inspectorVisible &&
|
||||
!isOverview &&
|
||||
!isKnowledgeView
|
||||
? 280
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{/* Quick Preview - isolated component to prevent frame rerenders on selection change */}
|
||||
<QuickPreviewController
|
||||
sidebarWidth={sidebarVisible ? 220 : 0}
|
||||
inspectorWidth={
|
||||
inspectorVisible && !isOverview && !isKnowledgeView
|
||||
? 280
|
||||
: 0
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</div>
|
||||
{/* Show count badge if dragging multiple files */}
|
||||
{activeItem.selectedFiles && activeItem.selectedFiles.length > 1 && (
|
||||
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-accent text-white text-xs font-bold flex items-center justify-center shadow-lg border-2 border-app">
|
||||
{activeItem.selectedFiles.length}
|
||||
</div>
|
||||
)}
|
||||
{activeItem.selectedFiles &&
|
||||
activeItem.selectedFiles.length > 1 && (
|
||||
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-accent text-white text-xs font-bold flex items-center justify-center shadow-lg border-2 border-app">
|
||||
{activeItem.selectedFiles.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Column/List view preview
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-accent text-white shadow-lg min-w-[200px] max-w-[300px]">
|
||||
<FileComponent.Thumb file={activeItem.file} size={24} />
|
||||
<span className="text-sm font-medium truncate">{activeItem.name}</span>
|
||||
<FileComponent.Thumb
|
||||
file={activeItem.file}
|
||||
size={24}
|
||||
/>
|
||||
<span className="text-sm font-medium truncate">
|
||||
{activeItem.name}
|
||||
</span>
|
||||
{/* Show count badge if dragging multiple files */}
|
||||
{activeItem.selectedFiles && activeItem.selectedFiles.length > 1 && (
|
||||
<div className="ml-auto size-5 rounded-full bg-white text-accent text-xs font-bold flex items-center justify-center">
|
||||
{activeItem.selectedFiles.length}
|
||||
</div>
|
||||
)}
|
||||
{activeItem.selectedFiles &&
|
||||
activeItem.selectedFiles.length > 1 && (
|
||||
<div className="ml-auto size-5 rounded-full bg-white text-accent text-xs font-bold flex items-center justify-center">
|
||||
{activeItem.selectedFiles.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
@@ -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<typeof useContextMenu>;
|
||||
}) {
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<ColumnItem
|
||||
file={file}
|
||||
selected={selected}
|
||||
focused={false}
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(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<string>;
|
||||
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<HTMLDivElement>(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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="shrink-0 border-r border-app-line flex items-center justify-center"
|
||||
style={{ width: `${viewSettings.columnWidth}px` }}
|
||||
>
|
||||
<div className="text-sm text-ink-dull">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (directoryQuery.isLoading) {
|
||||
return (
|
||||
<div
|
||||
className="shrink-0 border-r border-app-line flex items-center justify-center"
|
||||
style={{ width: `${viewSettings.columnWidth}px` }}
|
||||
>
|
||||
<div className="text-sm text-ink-dull">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={clsx(
|
||||
"shrink-0 border-r border-app-line overflow-auto",
|
||||
isActive && "bg-app-box/30"
|
||||
)}
|
||||
style={{ width: `${viewSettings.columnWidth}px` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const file = files[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className={clsx(
|
||||
"shrink-0 border-r border-app-line overflow-auto",
|
||||
isActive && "bg-app-box/30",
|
||||
)}
|
||||
style={{ width: `${viewSettings.columnWidth}px` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: "100%",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<ColumnItem
|
||||
file={file}
|
||||
selected={fileIsSelected || isInPath}
|
||||
focused={false}
|
||||
onClick={(multi, range) => onSelectFile(file, files, multi, range)}
|
||||
onContextMenu={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!fileIsSelected) {
|
||||
onSelectFile(file, files, false, false);
|
||||
}
|
||||
await contextMenu.show(e);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ColumnItemWrapper
|
||||
key={virtualRow.key}
|
||||
file={file}
|
||||
files={files}
|
||||
virtualRow={virtualRow}
|
||||
selected={fileIsSelected || isInPath}
|
||||
onSelectFile={onSelectFile}
|
||||
contextMenu={contextMenu}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
<FileComponent
|
||||
file={file}
|
||||
selected={selected && !isDragging}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
layout="row"
|
||||
data-file-id={file.id}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-1.5 mx-2 rounded-md cursor-default transition-none",
|
||||
selected && !isDragging
|
||||
? "bg-accent text-white"
|
||||
: "text-ink",
|
||||
focused && !selected && "ring-2 ring-accent/50",
|
||||
isDragging && "opacity-40"
|
||||
)}
|
||||
>
|
||||
<div className="[&_*]:!rounded-[3px] flex-shrink-0">
|
||||
<FileComponent.Thumb file={file} size={20} />
|
||||
</div>
|
||||
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||
{file.kind === "Directory" && (
|
||||
<svg
|
||||
className="size-3 text-ink-dull"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</FileComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
<FileComponent
|
||||
file={file}
|
||||
selected={selected && !isDragging}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
layout="row"
|
||||
data-file-id={file.id}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-1.5 mx-2 rounded-md cursor-default transition-none",
|
||||
selected && !isDragging
|
||||
? "bg-accent text-white"
|
||||
: "text-ink",
|
||||
focused && !selected && "ring-2 ring-accent/50",
|
||||
isDragging && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<div className="[&_*]:!rounded-[3px] flex-shrink-0">
|
||||
<FileComponent.Thumb file={file} size={20} />
|
||||
</div>
|
||||
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||
{file.kind === "Directory" && (
|
||||
<svg
|
||||
className="size-3 text-ink-dull"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</FileComponent>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(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;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<SdPath[]>([]);
|
||||
const isInternalNavigationRef = useRef(false);
|
||||
const { currentPath, setCurrentPath, sortBy, viewSettings } = useExplorer();
|
||||
const {
|
||||
selectedFiles,
|
||||
selectedFileIds,
|
||||
isSelected,
|
||||
selectFile,
|
||||
clearSelection,
|
||||
} = useSelection();
|
||||
const [columnStack, setColumnStack] = useState<SdPath[]>([]);
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-ink-dull">No location selected</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!currentPath) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-ink-dull">No location selected</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full overflow-x-auto bg-app">
|
||||
{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<string>();
|
||||
|
||||
return (
|
||||
<Column
|
||||
key={`${path.Physical?.device_slug}-${path.Physical?.path}-${index}`}
|
||||
path={path}
|
||||
selectedFiles={selectedFiles}
|
||||
onSelectFile={(file, files, multi, range) => handleSelectFile(file, index, files, multi, range)}
|
||||
onNavigate={handleNavigate}
|
||||
nextColumnPath={columnStack[index + 1]}
|
||||
columnIndex={index}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
const paths = new Set<string>();
|
||||
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 (
|
||||
<div className="flex h-full overflow-x-auto bg-app">
|
||||
{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 (
|
||||
<Column
|
||||
key={`${path.Physical?.device_slug}-${path.Physical?.path}-${index}`}
|
||||
path={path}
|
||||
isSelected={isSelected}
|
||||
selectedFileIds={selectedFileIds}
|
||||
onSelectFile={(file, files, multi, range) =>
|
||||
handleSelectFile(file, index, files, multi, range)
|
||||
}
|
||||
onNavigate={handleNavigate}
|
||||
nextColumnPath={columnStack[index + 1]}
|
||||
columnIndex={index}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const bodyScrollRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const bodyScrollRef = useRef<HTMLDivElement>(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<string, DirectorySortBy> = {
|
||||
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<string, DirectorySortBy> = {
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full overflow-auto"
|
||||
>
|
||||
{/* Sticky Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 border-b border-app-line bg-app/90 backdrop-blur-lg"
|
||||
style={{ height: TABLE_HEADER_HEIGHT }}
|
||||
>
|
||||
<div
|
||||
ref={headerScrollRef}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
width: totalWidth,
|
||||
paddingLeft: TABLE_PADDING_X,
|
||||
paddingRight: TABLE_PADDING_X,
|
||||
}}
|
||||
>
|
||||
{headerGroups.map((headerGroup) =>
|
||||
headerGroup.headers.map((header) => {
|
||||
const isSorted = sortBy === header.id;
|
||||
const canResize = header.column.getCanResize();
|
||||
return (
|
||||
<div ref={containerRef} className="h-full overflow-auto">
|
||||
{/* Sticky Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 border-b border-app-line bg-app/90 backdrop-blur-lg"
|
||||
style={{ height: TABLE_HEADER_HEIGHT }}
|
||||
>
|
||||
<div ref={headerScrollRef} className="overflow-hidden">
|
||||
<div
|
||||
className="flex"
|
||||
style={{
|
||||
width: totalWidth,
|
||||
paddingLeft: TABLE_PADDING_X,
|
||||
paddingRight: TABLE_PADDING_X,
|
||||
}}
|
||||
>
|
||||
{headerGroups.map((headerGroup) =>
|
||||
headerGroup.headers.map((header) => {
|
||||
const isSorted = sortBy === header.id;
|
||||
const canResize = header.column.getCanResize();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={clsx(
|
||||
"relative flex select-none items-center gap-1 px-2 py-2 text-xs font-medium",
|
||||
isSorted ? "text-ink" : "text-ink-dull",
|
||||
"cursor-pointer hover:text-ink"
|
||||
)}
|
||||
style={{ width: header.getSize() }}
|
||||
onClick={() => handleHeaderClick(header.id)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</span>
|
||||
return (
|
||||
<div
|
||||
key={header.id}
|
||||
className={clsx(
|
||||
"relative flex select-none items-center gap-1 px-2 py-2 text-xs font-medium",
|
||||
isSorted
|
||||
? "text-ink"
|
||||
: "text-ink-dull",
|
||||
"cursor-pointer hover:text-ink",
|
||||
)}
|
||||
style={{ width: header.getSize() }}
|
||||
onClick={() =>
|
||||
handleHeaderClick(header.id)
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</span>
|
||||
|
||||
{isSorted && (
|
||||
<CaretDown className="size-3 flex-shrink-0 text-ink-faint" />
|
||||
)}
|
||||
{isSorted && (
|
||||
<CaretDown className="size-3 flex-shrink-0 text-ink-faint" />
|
||||
)}
|
||||
|
||||
{/* Resize handle */}
|
||||
{canResize && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onClick={(e) => 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"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Resize handle */}
|
||||
{canResize && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
onClick={(e) =>
|
||||
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",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Virtual List Body */}
|
||||
<div
|
||||
ref={bodyScrollRef}
|
||||
className="overflow-x-auto"
|
||||
onScroll={handleBodyScroll}
|
||||
style={{ pointerEvents: 'auto' }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height: rowVirtualizer.getTotalSize() - TABLE_HEADER_HEIGHT,
|
||||
width: totalWidth,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${(virtualRows[0]?.start ?? 0) - TABLE_HEADER_HEIGHT - TABLE_PADDING_Y}px)`,
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
{/* Virtual List Body */}
|
||||
<div
|
||||
ref={bodyScrollRef}
|
||||
className="overflow-x-auto"
|
||||
onScroll={handleBodyScroll}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
>
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
height:
|
||||
rowVirtualizer.getTotalSize() - TABLE_HEADER_HEIGHT,
|
||||
width: totalWidth,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
transform: `translateY(${(virtualRows[0]?.start ?? 0) - TABLE_HEADER_HEIGHT - TABLE_PADDING_Y}px)`,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
file={file}
|
||||
files={files}
|
||||
index={virtualRow.index}
|
||||
isSelected={isSelected}
|
||||
isFocused={isFocused}
|
||||
isPreviousSelected={isPreviousSelected}
|
||||
isNextSelected={isNextSelected}
|
||||
measureRef={rowVirtualizer.measureElement}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
file={file}
|
||||
files={files}
|
||||
index={virtualRow.index}
|
||||
isSelected={fileIsSelected}
|
||||
isFocused={isFocused}
|
||||
isPreviousSelected={isPreviousSelected}
|
||||
isNextSelected={isNextSelected}
|
||||
measureRef={rowVirtualizer.measureElement}
|
||||
selectFile={selectFile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: File;
|
||||
files: File[];
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
isFocused: boolean;
|
||||
isPreviousSelected: boolean;
|
||||
isNextSelected: boolean;
|
||||
measureRef: (node: HTMLElement | null) => void;
|
||||
row: Row<File>;
|
||||
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 (
|
||||
<div
|
||||
ref={measureRef}
|
||||
data-index={index}
|
||||
data-file-id={file.id}
|
||||
className="relative"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Background layer for alternating colors and selection */}
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-0 rounded-md border",
|
||||
// Alternating background
|
||||
index % 2 === 0 && !isSelected && "bg-app-darkBox/50",
|
||||
// Selection styling
|
||||
isSelected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-transparent",
|
||||
// Connect adjacent selected rows
|
||||
isSelected && isPreviousSelected && "rounded-t-none border-t-0",
|
||||
isSelected && isNextSelected && "rounded-b-none border-b-0"
|
||||
)}
|
||||
style={{
|
||||
left: TABLE_PADDING_X,
|
||||
right: TABLE_PADDING_X,
|
||||
}}
|
||||
>
|
||||
{/* Subtle separator between connected selected rows */}
|
||||
{isSelected && isPreviousSelected && (
|
||||
<div className="absolute inset-x-3 top-0 h-px bg-accent/20" />
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
ref={measureRef}
|
||||
data-index={index}
|
||||
data-file-id={file.id}
|
||||
className="relative"
|
||||
style={{ height: ROW_HEIGHT }}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
>
|
||||
{/* Background layer for alternating colors and selection */}
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-0 rounded-md border",
|
||||
// Alternating background
|
||||
index % 2 === 0 && !isSelected && "bg-app-darkBox/50",
|
||||
// Selection styling
|
||||
isSelected
|
||||
? "border-accent bg-accent/10"
|
||||
: "border-transparent",
|
||||
// Connect adjacent selected rows
|
||||
isSelected &&
|
||||
isPreviousSelected &&
|
||||
"rounded-t-none border-t-0",
|
||||
isSelected &&
|
||||
isNextSelected &&
|
||||
"rounded-b-none border-b-0",
|
||||
)}
|
||||
style={{
|
||||
left: TABLE_PADDING_X,
|
||||
right: TABLE_PADDING_X,
|
||||
}}
|
||||
>
|
||||
{/* Subtle separator between connected selected rows */}
|
||||
{isSelected && isPreviousSelected && (
|
||||
<div className="absolute inset-x-3 top-0 h-px bg-accent/20" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row content */}
|
||||
<div
|
||||
className="relative flex h-full items-center"
|
||||
style={{
|
||||
paddingLeft: TABLE_PADDING_X,
|
||||
paddingRight: TABLE_PADDING_X,
|
||||
}}
|
||||
>
|
||||
{cells.map((cell) => {
|
||||
const isNameColumn = cell.column.id === "name";
|
||||
{/* Row content */}
|
||||
<div
|
||||
className="relative flex h-full items-center"
|
||||
style={{
|
||||
paddingLeft: TABLE_PADDING_X,
|
||||
paddingRight: TABLE_PADDING_X,
|
||||
}}
|
||||
>
|
||||
{cells.map((cell) => {
|
||||
const isNameColumn = cell.column.id === "name";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={clsx(
|
||||
"flex h-full items-center px-2 text-sm",
|
||||
isNameColumn ? "min-w-0 flex-1" : "text-ink-dull"
|
||||
)}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{isNameColumn ? (
|
||||
<NameCell file={file} />
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={clsx(
|
||||
"flex h-full items-center px-2 text-sm",
|
||||
isNameColumn
|
||||
? "min-w-0 flex-1"
|
||||
: "text-ink-dull",
|
||||
)}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{isNameColumn ? (
|
||||
<NameCell file={file} />
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(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 (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{/* File icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<FileComponent.Thumb file={file} size={20} />
|
||||
</div>
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{/* File icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<FileComponent.Thumb file={file} size={20} />
|
||||
</div>
|
||||
|
||||
{/* File name */}
|
||||
<span className="truncate text-sm text-ink">
|
||||
{file.name}
|
||||
</span>
|
||||
{/* File name */}
|
||||
<span className="truncate text-sm text-ink">{file.name}</span>
|
||||
|
||||
{/* Tags (inline, compact) */}
|
||||
{file.tags && file.tags.length > 0 && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
{file.tags.slice(0, 2).map((tag) => (
|
||||
<TagPill
|
||||
key={tag.id}
|
||||
color={tag.color || "#3B82F6"}
|
||||
size="xs"
|
||||
>
|
||||
{tag.canonical_name}
|
||||
</TagPill>
|
||||
))}
|
||||
{file.tags.length > 2 && (
|
||||
<span className="text-[10px] text-ink-faint">
|
||||
+{file.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
{/* Tags (inline, compact) */}
|
||||
{file.tags && file.tags.length > 0 && (
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
{file.tags.slice(0, 2).map((tag) => (
|
||||
<TagPill
|
||||
key={tag.id}
|
||||
color={tag.color || "#3B82F6"}
|
||||
size="xs"
|
||||
>
|
||||
{tag.canonical_name}
|
||||
</TagPill>
|
||||
))}
|
||||
{file.tags.length > 2 && (
|
||||
<span className="text-[10px] text-ink-faint">
|
||||
+{file.tags.length - 2}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -191,10 +191,10 @@ export function useNormalizedQuery<I, O>(
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user