feat: Implement search functionality and UI improvements

Co-authored-by: ijamespine <ijamespine@me.com>
This commit is contained in:
Cursor Agent
2025-12-25 17:11:24 +00:00
parent b452871a67
commit 98a342e974
5 changed files with 540 additions and 47 deletions

View File

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

View File

@@ -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" ? (

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

View File

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

View File

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