From 98a342e974325bf5eff85daf008ea4f38f56f502 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 25 Dec 2025 17:11:24 +0000 Subject: [PATCH] feat: Implement search functionality and UI improvements Co-authored-by: ijamespine --- core/src/ops/search/ephemeral_search.rs | 41 +-- .../src/components/Explorer/ExplorerView.tsx | 45 ++- .../src/components/Explorer/SearchToolbar.tsx | 99 ++++++ .../src/components/Explorer/context.tsx | 93 +++++- .../Explorer/views/SearchView/SearchView.tsx | 309 +++++++++++++++++- 5 files changed, 540 insertions(+), 47 deletions(-) create mode 100644 packages/interface/src/components/Explorer/SearchToolbar.tsx diff --git a/core/src/ops/search/ephemeral_search.rs b/core/src/ops/search/ephemeral_search.rs index b123d5551..f8d31fd15 100644 --- a/core/src/ops/search/ephemeral_search.rs +++ b/core/src/ops/search/ephemeral_search.rs @@ -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 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::::from(system_time); - let date = DateTime::::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) { diff --git a/packages/interface/src/components/Explorer/ExplorerView.tsx b/packages/interface/src/components/Explorer/ExplorerView.tsx index cf99470a8..09adc6975 100644 --- a/packages/interface/src/components/Explorer/ExplorerView.tsx +++ b/packages/interface/src/components/Explorer/ExplorerView.tsx @@ -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 ; @@ -99,7 +134,14 @@ export function ExplorerView() {
+ {mode.type === "search" && }
{mode.type === "search" ? ( diff --git a/packages/interface/src/components/Explorer/SearchToolbar.tsx b/packages/interface/src/components/Explorer/SearchToolbar.tsx new file mode 100644 index 000000000..4e501ca42 --- /dev/null +++ b/packages/interface/src/components/Explorer/SearchToolbar.tsx @@ -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 ( +
+
+ + Search in: + +
+ handleScopeChange("folder")} + > + This Folder + + handleScopeChange("location")} + > + Location + + handleScopeChange("library")} + > + Library + +
+
+ +
+ + + +
+ + +
+ ); +} + +interface ScopeButtonProps { + active: boolean; + onClick: () => void; + children: React.ReactNode; +} + +function ScopeButton({ active, onClick, children }: ScopeButtonProps) { + return ( + + ); +} diff --git a/packages/interface/src/components/Explorer/context.tsx b/packages/interface/src/components/Explorer/context.tsx index e6c47733d..97d24e9fa 100644 --- a/packages/interface/src/components/Explorer/context.tsx +++ b/packages/interface/src/components/Explorer/context.tsx @@ -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; 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 ( diff --git a/packages/interface/src/components/Explorer/views/SearchView/SearchView.tsx b/packages/interface/src/components/Explorer/views/SearchView/SearchView.tsx index fc764b235..2b7d2678d 100644 --- a/packages/interface/src/components/Explorer/views/SearchView/SearchView.tsx +++ b/packages/interface/src/components/Explorer/views/SearchView/SearchView.tsx @@ -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 ( +
+

+ Type at least 2 characters to search +

+
+ ); + } + + if (searchQuery.isLoading) { + return ( +
+

Searching...

+
+ ); + } + + if (files.length === 0) { + return ( +
+

No results found

+

+ Try a different search term or adjust your filters +

+
+ ); + } + + if (viewMode === "grid") { + return ; + } + + if (viewMode === "list") { + return ; + } - // TODO: Implement actual search query - // For now, just render a placeholder message return (
-

Search

-

- Searching for: {query} -

-

- Search implementation in progress... +

+ Search results in {viewMode} view coming soon

); } + +function SearchGridView({ files }: { files: File[] }) { + const explorer = useExplorer(); + const { isSelected, focusedIndex, setFocusedIndex, selectFile } = useSelection(); + const { gridSize, gapSize } = explorer.viewSettings; + + const containerRef = useRef(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 ( +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const startIdx = virtualRow.index * columnsCount; + const rowFiles = files.slice(startIdx, startIdx + columnsCount); + + return ( +
+
+ {rowFiles.map((file, colIndex) => { + const fileIndex = startIdx + colIndex; + return ( + selectFile(file, fileIndex, e)} + onFocus={() => setFocusedIndex(fileIndex)} + /> + ); + })} +
+
+ ); + })} +
+
+ ); +} + +function SearchListView({ files }: { files: File[] }) { + const explorer = useExplorer(); + const { focusedIndex, setFocusedIndex, isSelected, selectFile } = useSelection(); + const { sortBy, setSortBy } = explorer; + + const containerRef = useRef(null); + const headerScrollRef = useRef(null); + const bodyScrollRef = useRef(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 ( +
+
+
+ {table.getHeaderGroups().map((headerGroup) => + headerGroup.headers.map((header) => ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} + {header.column.getIsSorted() && ( + + )} +
+ )) + )} +
+
+ +
+
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const file = files[virtualRow.index]; + const row = table.getRowModel().rows[virtualRow.index]; + + return ( +
+ + selectFile(file, virtualRow.index, e) + } + onFocus={() => setFocusedIndex(virtualRow.index)} + /> +
+ ); + })} +
+
+
+ ); +}