mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-19 13:55:40 -04:00
feat: Implement search functionality and UI improvements
Co-authored-by: ijamespine <ijamespine@me.com>
This commit is contained in:
@@ -13,7 +13,6 @@ use crate::ops::search::input::{DateField, SearchFilters};
|
||||
use crate::ops::search::output::{FileSearchResult, ScoreBreakdown};
|
||||
use std::cmp::Ordering;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Search the ephemeral index for files matching the query
|
||||
@@ -74,10 +73,10 @@ pub async fn search_ephemeral_index(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if !passes_ephemeral_filters(&metadata, &path, filters, file_type_registry) {
|
||||
continue;
|
||||
}
|
||||
// Apply filters
|
||||
if !passes_ephemeral_filters(&metadata, filters, file_type_registry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get UUID
|
||||
let uuid = index.get_entry_uuid(&path).unwrap_or_else(Uuid::new_v4);
|
||||
@@ -121,7 +120,6 @@ pub async fn search_ephemeral_index(
|
||||
/// Check if metadata passes ephemeral filters
|
||||
fn passes_ephemeral_filters(
|
||||
metadata: &EntryMetadata,
|
||||
path: &PathBuf,
|
||||
filters: &SearchFilters,
|
||||
file_type_registry: &FileTypeRegistry,
|
||||
) -> bool {
|
||||
@@ -156,29 +154,24 @@ fn passes_ephemeral_filters(
|
||||
if let Some(ref range) = filters.date_range {
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
// metadata.modified, created, and accessed are Option<SystemTime>
|
||||
let system_time_opt = match range.field {
|
||||
DateField::ModifiedAt => metadata.modified,
|
||||
DateField::CreatedAt => metadata.created.or(metadata.modified),
|
||||
DateField::AccessedAt => metadata.accessed.or(metadata.modified),
|
||||
DateField::CreatedAt => metadata.created,
|
||||
DateField::AccessedAt => metadata.accessed,
|
||||
};
|
||||
|
||||
// If we don't have a timestamp, skip this filter check
|
||||
let system_time = match system_time_opt {
|
||||
Some(time) => time,
|
||||
None => return true, // No timestamp available, allow the file
|
||||
};
|
||||
if let Some(system_time) = system_time_opt {
|
||||
let date = DateTime::<Utc>::from(system_time);
|
||||
|
||||
let date = DateTime::<Utc>::from(system_time);
|
||||
|
||||
if let Some(start) = range.start {
|
||||
if date < start {
|
||||
return false;
|
||||
if let Some(start) = range.start {
|
||||
if date < start {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(end) = range.end {
|
||||
if date > end {
|
||||
return false;
|
||||
if let Some(end) = range.end {
|
||||
if date > end {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -186,7 +179,7 @@ fn passes_ephemeral_filters(
|
||||
// Content type filter (via extension using FileTypeRegistry)
|
||||
if let Some(ref content_types) = filters.content_types {
|
||||
// Use FileTypeRegistry to identify content kind by extension
|
||||
let identified_kind = file_type_registry.identify_by_extension(path);
|
||||
let identified_kind = file_type_registry.identify_by_extension(&metadata.path);
|
||||
|
||||
// Check if the identified kind matches any of the requested types
|
||||
if !content_types.contains(&identified_kind) {
|
||||
|
||||
@@ -6,6 +6,8 @@ import { ColumnView } from "./views/ColumnView";
|
||||
import { SizeView } from "./views/SizeView";
|
||||
import { KnowledgeView } from "./views/KnowledgeView";
|
||||
import { EmptyView } from "./views/EmptyView";
|
||||
import { SearchView } from "./views/SearchView";
|
||||
import { SearchToolbar } from "./SearchToolbar";
|
||||
import { TopBarPortal } from "../../TopBar";
|
||||
import { useVirtualListing } from "./hooks/useVirtualListing";
|
||||
import { VirtualPathBar } from "./components/VirtualPathBar";
|
||||
@@ -22,6 +24,7 @@ import { ViewSettings } from "../Explorer/ViewSettings";
|
||||
import { SortMenu } from "./SortMenu";
|
||||
import { ViewModeMenu } from "./ViewModeMenu";
|
||||
import { TabNavigationGuard } from "./TabNavigationGuard";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export function ExplorerView() {
|
||||
const {
|
||||
@@ -45,11 +48,43 @@ export function ExplorerView() {
|
||||
navigateToPath,
|
||||
devices,
|
||||
quickPreviewFileId,
|
||||
mode,
|
||||
enterSearchMode,
|
||||
exitSearchMode,
|
||||
} = useExplorer();
|
||||
|
||||
const { isVirtualView } = useVirtualListing();
|
||||
const isPreviewActive = !!quickPreviewFileId;
|
||||
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchValue(value);
|
||||
|
||||
if (value.length >= 2) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
enterSearchMode(value);
|
||||
}, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else if (value.length === 0 && mode.type === "search") {
|
||||
exitSearchMode();
|
||||
}
|
||||
},
|
||||
[enterSearchMode, exitSearchMode, mode.type]
|
||||
);
|
||||
|
||||
const handleSearchClear = useCallback(() => {
|
||||
setSearchValue("");
|
||||
exitSearchMode();
|
||||
}, [exitSearchMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode.type !== "search") {
|
||||
setSearchValue("");
|
||||
}
|
||||
}, [mode.type]);
|
||||
|
||||
// Allow rendering if either we have a currentPath or we're in a virtual view
|
||||
if (!currentPath && !isVirtualView) {
|
||||
return <EmptyView />;
|
||||
@@ -99,7 +134,14 @@ export function ExplorerView() {
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchBar
|
||||
className="w-64"
|
||||
placeholder="Search..."
|
||||
placeholder={
|
||||
currentPath
|
||||
? "Search in current folder..."
|
||||
: "Search..."
|
||||
}
|
||||
value={searchValue}
|
||||
onChange={handleSearchChange}
|
||||
onClear={handleSearchClear}
|
||||
/>
|
||||
<TopBarButton
|
||||
icon={TagIcon}
|
||||
@@ -129,6 +171,7 @@ export function ExplorerView() {
|
||||
)}
|
||||
|
||||
<div className="relative flex w-full flex-col pt-1.5 h-full overflow-hidden bg-app/80">
|
||||
{mode.type === "search" && <SearchToolbar />}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<TabNavigationGuard>
|
||||
{mode.type === "search" ? (
|
||||
|
||||
99
packages/interface/src/components/Explorer/SearchToolbar.tsx
Normal file
99
packages/interface/src/components/Explorer/SearchToolbar.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { FunnelSimple, X } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import { useExplorer } from "./context";
|
||||
import type { SearchScope } from "./context";
|
||||
|
||||
export function SearchToolbar() {
|
||||
const explorer = useExplorer();
|
||||
|
||||
if (explorer.mode.type !== "search") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { scope } = explorer.mode;
|
||||
|
||||
const handleScopeChange = (newScope: SearchScope) => {
|
||||
if (explorer.mode.type === "search") {
|
||||
explorer.enterSearchMode(explorer.mode.query, newScope);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 border-b border-sidebar-line/30 bg-sidebar-box/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-sidebar-inkDull">
|
||||
Search in:
|
||||
</span>
|
||||
<div className="flex items-center gap-1 rounded-lg bg-sidebar-box/30 p-0.5">
|
||||
<ScopeButton
|
||||
active={scope === "folder"}
|
||||
onClick={() => handleScopeChange("folder")}
|
||||
>
|
||||
This Folder
|
||||
</ScopeButton>
|
||||
<ScopeButton
|
||||
active={scope === "location"}
|
||||
onClick={() => handleScopeChange("location")}
|
||||
>
|
||||
Location
|
||||
</ScopeButton>
|
||||
<ScopeButton
|
||||
active={scope === "library"}
|
||||
onClick={() => handleScopeChange("library")}
|
||||
>
|
||||
Library
|
||||
</ScopeButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-px bg-sidebar-line/30" />
|
||||
|
||||
<button
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 px-2 py-1 rounded-md",
|
||||
"text-xs font-medium text-sidebar-ink",
|
||||
"hover:bg-sidebar-selected/40 transition-colors"
|
||||
)}
|
||||
>
|
||||
<FunnelSimple className="size-3.5" weight="bold" />
|
||||
Filters
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<button
|
||||
onClick={explorer.exitSearchMode}
|
||||
className={clsx(
|
||||
"flex items-center gap-1.5 px-2 py-1 rounded-md",
|
||||
"text-xs font-medium text-sidebar-inkDull",
|
||||
"hover:bg-sidebar-selected/40 hover:text-sidebar-ink transition-colors"
|
||||
)}
|
||||
>
|
||||
<X className="size-3.5" weight="bold" />
|
||||
Clear Search
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ScopeButtonProps {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function ScopeButton({ active, onClick, children }: ScopeButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"px-3 py-1 rounded-md text-xs font-medium transition-all",
|
||||
active
|
||||
? "bg-accent text-white shadow-sm"
|
||||
: "text-sidebar-inkDull hover:text-sidebar-ink hover:bg-sidebar-selected/30"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,22 @@ export interface ViewSettings {
|
||||
foldersFirst: boolean;
|
||||
}
|
||||
|
||||
export type SearchScope = "folder" | "location" | "library";
|
||||
|
||||
export interface SearchFilters {
|
||||
fileTypes?: string[];
|
||||
contentTypes?: string[];
|
||||
sizeMin?: number;
|
||||
sizeMax?: number;
|
||||
dateModifiedStart?: Date;
|
||||
dateModifiedEnd?: Date;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export type ExplorerMode =
|
||||
| { type: "browse" }
|
||||
| { type: "search"; query: string; scope: SearchScope };
|
||||
|
||||
export type NavigationTarget =
|
||||
| { type: "path"; path: SdPath }
|
||||
| {
|
||||
@@ -159,6 +175,8 @@ interface UIState {
|
||||
inspectorVisible: boolean;
|
||||
quickPreviewFileId: string | null;
|
||||
tagModeActive: boolean;
|
||||
mode: ExplorerMode;
|
||||
searchFilters: SearchFilters;
|
||||
}
|
||||
|
||||
type UIAction =
|
||||
@@ -169,6 +187,9 @@ type UIAction =
|
||||
| { type: "SET_INSPECTOR_VISIBLE"; visible: boolean }
|
||||
| { type: "SET_QUICK_PREVIEW"; fileId: string | null }
|
||||
| { type: "SET_TAG_MODE"; active: boolean }
|
||||
| { type: "ENTER_SEARCH_MODE"; query: string; scope: SearchScope }
|
||||
| { type: "EXIT_SEARCH_MODE" }
|
||||
| { type: "SET_SEARCH_FILTERS"; filters: SearchFilters }
|
||||
| {
|
||||
type: "LOAD_PREFERENCES";
|
||||
viewMode: ViewMode;
|
||||
@@ -209,6 +230,25 @@ function uiReducer(state: UIState, action: UIAction): UIState {
|
||||
case "SET_TAG_MODE":
|
||||
return { ...state, tagModeActive: action.active };
|
||||
|
||||
case "ENTER_SEARCH_MODE":
|
||||
return {
|
||||
...state,
|
||||
mode: { type: "search", query: action.query, scope: action.scope },
|
||||
};
|
||||
|
||||
case "EXIT_SEARCH_MODE":
|
||||
return {
|
||||
...state,
|
||||
mode: { type: "browse" },
|
||||
searchFilters: {},
|
||||
};
|
||||
|
||||
case "SET_SEARCH_FILTERS":
|
||||
return {
|
||||
...state,
|
||||
searchFilters: action.filters,
|
||||
};
|
||||
|
||||
case "LOAD_PREFERENCES":
|
||||
return {
|
||||
...state,
|
||||
@@ -231,6 +271,8 @@ const initialUIState: UIState = {
|
||||
inspectorVisible: true,
|
||||
quickPreviewFileId: null,
|
||||
tagModeActive: false,
|
||||
mode: { type: "browse" },
|
||||
searchFilters: {},
|
||||
};
|
||||
|
||||
function targetToUrl(target: NavigationTarget): string {
|
||||
@@ -344,6 +386,12 @@ interface ExplorerContextValue {
|
||||
tagModeActive: boolean;
|
||||
setTagModeActive: (active: boolean) => void;
|
||||
|
||||
mode: ExplorerMode;
|
||||
enterSearchMode: (query: string, scope?: SearchScope) => void;
|
||||
exitSearchMode: () => void;
|
||||
searchFilters: SearchFilters;
|
||||
setSearchFilters: (filters: SearchFilters) => void;
|
||||
|
||||
devices: Map<string, Device>;
|
||||
|
||||
loadPreferencesForSpaceItem: (id: string) => void;
|
||||
@@ -610,6 +658,21 @@ export function ExplorerProvider({
|
||||
uiDispatch({ type: "SET_TAG_MODE", active });
|
||||
}, []);
|
||||
|
||||
const enterSearchMode = useCallback(
|
||||
(query: string, scope: SearchScope = "folder") => {
|
||||
uiDispatch({ type: "ENTER_SEARCH_MODE", query, scope });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const exitSearchMode = useCallback(() => {
|
||||
uiDispatch({ type: "EXIT_SEARCH_MODE" });
|
||||
}, []);
|
||||
|
||||
const setSearchFilters = useCallback((filters: SearchFilters) => {
|
||||
uiDispatch({ type: "SET_SEARCH_FILTERS", filters });
|
||||
}, []);
|
||||
|
||||
const loadPreferencesForSpaceItem = useCallback(
|
||||
(id: string) => {
|
||||
const prefs = viewPrefs.getPreferences(id);
|
||||
@@ -656,6 +719,11 @@ export function ExplorerProvider({
|
||||
setCurrentFiles,
|
||||
tagModeActive: uiState.tagModeActive,
|
||||
setTagModeActive,
|
||||
mode: uiState.mode,
|
||||
enterSearchMode,
|
||||
exitSearchMode,
|
||||
searchFilters: uiState.searchFilters,
|
||||
setSearchFilters,
|
||||
devices,
|
||||
loadPreferencesForSpaceItem,
|
||||
activeTabId,
|
||||
@@ -687,18 +755,19 @@ export function ExplorerProvider({
|
||||
uiState.quickPreviewFileId,
|
||||
openQuickPreview,
|
||||
closeQuickPreview,
|
||||
currentFiles,
|
||||
uiState.tagModeActive,
|
||||
setTagModeActive,
|
||||
devices,
|
||||
loadPreferencesForSpaceItem,
|
||||
mode,
|
||||
enterSearchMode,
|
||||
exitSearchMode,
|
||||
searchFilters,
|
||||
activeTabId,
|
||||
],
|
||||
);
|
||||
currentFiles,
|
||||
uiState.tagModeActive,
|
||||
setTagModeActive,
|
||||
uiState.mode,
|
||||
enterSearchMode,
|
||||
exitSearchMode,
|
||||
uiState.searchFilters,
|
||||
setSearchFilters,
|
||||
devices,
|
||||
loadPreferencesForSpaceItem,
|
||||
activeTabId,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ExplorerContext.Provider value={value}>
|
||||
|
||||
@@ -1,27 +1,316 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useExplorer } from "../../context";
|
||||
import { GridView } from "../GridView";
|
||||
import { useSelection } from "../../SelectionContext";
|
||||
import { useNormalizedQuery } from "../../../../context";
|
||||
import { FileCard } from "../GridView/FileCard";
|
||||
import { TableRow } from "../ListView/TableRow";
|
||||
import { useTable, ROW_HEIGHT, TABLE_PADDING_X, TABLE_PADDING_Y, TABLE_HEADER_HEIGHT } from "../ListView/useTable";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import { CaretDown } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import type { File } from "@sd/ts-client";
|
||||
|
||||
export function SearchView() {
|
||||
const explorer = useExplorer();
|
||||
const {
|
||||
isSelected,
|
||||
focusedIndex,
|
||||
setFocusedIndex,
|
||||
selectedFiles,
|
||||
selectFile,
|
||||
clearSelection,
|
||||
setSelectedFiles,
|
||||
} = useSelection();
|
||||
|
||||
// If not in search mode, don't render
|
||||
if (explorer.mode.type !== "search") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { query, scope } = explorer.mode;
|
||||
const { viewMode, viewSettings, sortBy, setSortBy, currentPath } = explorer;
|
||||
const { gridSize, gapSize } = viewSettings;
|
||||
|
||||
const searchQuery = useNormalizedQuery({
|
||||
wireMethod: "query:search.files",
|
||||
input: {
|
||||
query,
|
||||
scope:
|
||||
scope === "folder" && currentPath
|
||||
? { type: "path", path: currentPath }
|
||||
: scope === "location"
|
||||
? { type: "location" }
|
||||
: { type: "library" },
|
||||
filters: explorer.searchFilters,
|
||||
limit: 1000,
|
||||
},
|
||||
resourceType: "file",
|
||||
enabled: query.length >= 2,
|
||||
});
|
||||
|
||||
const files = (searchQuery.data as any)?.results || [];
|
||||
|
||||
useEffect(() => {
|
||||
explorer.setCurrentFiles(files);
|
||||
}, [files, explorer.setCurrentFiles]);
|
||||
|
||||
if (query.length < 2) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-ink-dull text-sm">
|
||||
Type at least 2 characters to search
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (searchQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-ink-dull text-sm">Searching...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<p className="text-ink-dull mb-2">No results found</p>
|
||||
<p className="text-ink-faint text-sm">
|
||||
Try a different search term or adjust your filters
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (viewMode === "grid") {
|
||||
return <SearchGridView files={files} />;
|
||||
}
|
||||
|
||||
if (viewMode === "list") {
|
||||
return <SearchListView files={files} />;
|
||||
}
|
||||
|
||||
// TODO: Implement actual search query
|
||||
// For now, just render a placeholder message
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full p-8 text-center">
|
||||
<h2 className="text-2xl font-bold mb-4">Search</h2>
|
||||
<p className="text-ink-dull mb-4">
|
||||
Searching for: <span className="font-mono">{query}</span>
|
||||
</p>
|
||||
<p className="text-ink-faint text-sm">
|
||||
Search implementation in progress...
|
||||
<p className="text-ink-dull text-sm">
|
||||
Search results in {viewMode} view coming soon
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchGridView({ files }: { files: File[] }) {
|
||||
const explorer = useExplorer();
|
||||
const { isSelected, focusedIndex, setFocusedIndex, selectFile } = useSelection();
|
||||
const { gridSize, gapSize } = explorer.viewSettings;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (containerRef.current) {
|
||||
setContainerWidth(containerRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
updateWidth();
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => window.removeEventListener("resize", updateWidth);
|
||||
}, []);
|
||||
|
||||
const padding = 24;
|
||||
const itemWidth = gridSize;
|
||||
const itemHeight = gridSize + 40;
|
||||
const columnsCount = Math.max(
|
||||
1,
|
||||
Math.floor((containerWidth - padding * 2 + gapSize) / (itemWidth + gapSize))
|
||||
);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: Math.ceil(files.length / columnsCount),
|
||||
getScrollElement: () => containerRef.current,
|
||||
estimateSize: () => itemHeight + gapSize,
|
||||
overscan: 3,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="h-full overflow-auto px-6 py-4">
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const startIdx = virtualRow.index * columnsCount;
|
||||
const rowFiles = files.slice(startIdx, startIdx + columnsCount);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${columnsCount}, ${itemWidth}px)`,
|
||||
gap: `${gapSize}px`,
|
||||
}}
|
||||
>
|
||||
{rowFiles.map((file, colIndex) => {
|
||||
const fileIndex = startIdx + colIndex;
|
||||
return (
|
||||
<FileCard
|
||||
key={file.id}
|
||||
file={file}
|
||||
selected={isSelected(file.id)}
|
||||
focused={focusedIndex === fileIndex}
|
||||
onSelect={(e) => selectFile(file, fileIndex, e)}
|
||||
onFocus={() => setFocusedIndex(fileIndex)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchListView({ files }: { files: File[] }) {
|
||||
const explorer = useExplorer();
|
||||
const { focusedIndex, setFocusedIndex, isSelected, selectFile } = useSelection();
|
||||
const { sortBy, setSortBy } = explorer;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||
const bodyScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const table = useTable({
|
||||
files,
|
||||
sortBy,
|
||||
onSortChange: setSortBy,
|
||||
});
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: files.length,
|
||||
getScrollElement: () => bodyScrollRef.current,
|
||||
estimateSize: () => ROW_HEIGHT,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const handleBodyScroll = () => {
|
||||
if (bodyScrollRef.current && headerScrollRef.current) {
|
||||
headerScrollRef.current.scrollLeft = bodyScrollRef.current.scrollLeft;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col h-full">
|
||||
<div
|
||||
ref={headerScrollRef}
|
||||
className="overflow-hidden"
|
||||
style={{
|
||||
paddingLeft: TABLE_PADDING_X,
|
||||
paddingRight: TABLE_PADDING_X,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: table.getTotalSize(),
|
||||
height: TABLE_HEADER_HEIGHT,
|
||||
}}
|
||||
className="flex items-center border-b border-sidebar-line/30"
|
||||
>
|
||||
{table.getHeaderGroups().map((headerGroup) =>
|
||||
headerGroup.headers.map((header) => (
|
||||
<div
|
||||
key={header.id}
|
||||
style={{ width: header.getSize() }}
|
||||
className={clsx(
|
||||
"flex items-center gap-1 px-3 text-xs font-medium text-sidebar-inkDull select-none",
|
||||
header.column.getCanSort() &&
|
||||
"cursor-pointer hover:text-sidebar-ink"
|
||||
)}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{header.column.getIsSorted() && (
|
||||
<CaretDown
|
||||
className={clsx(
|
||||
"size-3 transition-transform",
|
||||
header.column.getIsSorted() === "asc" &&
|
||||
"rotate-180"
|
||||
)}
|
||||
weight="bold"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={bodyScrollRef}
|
||||
onScroll={handleBodyScroll}
|
||||
className="flex-1 overflow-auto"
|
||||
style={{
|
||||
paddingLeft: TABLE_PADDING_X,
|
||||
paddingRight: TABLE_PADDING_X,
|
||||
paddingTop: TABLE_PADDING_Y,
|
||||
paddingBottom: TABLE_PADDING_Y,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const file = files[virtualRow.index];
|
||||
const row = table.getRowModel().rows[virtualRow.index];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<TableRow
|
||||
row={row}
|
||||
file={file}
|
||||
selected={isSelected(file.id)}
|
||||
focused={focusedIndex === virtualRow.index}
|
||||
onSelect={(e) =>
|
||||
selectFile(file, virtualRow.index, e)
|
||||
}
|
||||
onFocus={() => setFocusedIndex(virtualRow.index)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user