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:
Jamie Pine
2025-12-09 19:03:51 -08:00
parent 34a00c152a
commit 05554b2919
10 changed files with 1412 additions and 1099 deletions

View File

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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