From 25197fde6d94b44458e26b90f080765ef6ed495b Mon Sep 17 00:00:00 2001 From: Jamie Pine Date: Thu, 22 Jan 2026 12:30:01 -0800 Subject: [PATCH] feat(mobile): add Jobs screen and enhance search functionality - Introduced a new Jobs screen to display and manage job statuses with controls for pause, resume, and cancel actions. - Updated RootLayout to include navigation to the new Jobs screen. - Enhanced SearchScreen with improved search capabilities, including auto-focus and dynamic filtering based on user input. - Integrated GlassContextMenu and SearchToolbar components for better user interaction during searches. - Updated GlassSearchBar to support controlled input and clear functionality. - Refactored ExplorerScreen to incorporate search context and improve file browsing experience. --- apps/mobile/src/app/_layout.tsx | 7 + apps/mobile/src/app/jobs.tsx | 259 ++++++++++++++++++ apps/mobile/src/app/search.tsx | 160 ++++++++++- apps/mobile/src/client/index.ts | 1 + .../src/components/GlassContextMenu.tsx | 142 ++++++++++ apps/mobile/src/components/GlassSearchBar.tsx | 124 +++++++-- apps/mobile/src/components/SearchToolbar.tsx | 135 +++++++++ .../src/screens/browse/BrowseScreen.tsx | 8 +- .../src/screens/explorer/ExplorerScreen.tsx | 105 ++++--- .../explorer/context/SearchContext.tsx | 62 +++++ .../explorer/hooks/useExplorerFiles.ts | 67 +++-- .../src/screens/overview/OverviewScreen.tsx | 68 ++++- packages/ts-client/src/hooks/index.ts | 1 + .../ts-client/src/hooks/useSearchFiles.ts | 149 ++++++++++ 14 files changed, 1199 insertions(+), 89 deletions(-) create mode 100644 apps/mobile/src/app/jobs.tsx create mode 100644 apps/mobile/src/components/GlassContextMenu.tsx create mode 100644 apps/mobile/src/components/SearchToolbar.tsx create mode 100644 apps/mobile/src/screens/explorer/context/SearchContext.tsx create mode 100644 packages/ts-client/src/hooks/useSearchFiles.ts diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 5b59aa7d1..96c4b7047 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -27,6 +27,13 @@ export default function RootLayout() { animation: 'slide_from_bottom' }} /> + diff --git a/apps/mobile/src/app/jobs.tsx b/apps/mobile/src/app/jobs.tsx new file mode 100644 index 000000000..740ba395a --- /dev/null +++ b/apps/mobile/src/app/jobs.tsx @@ -0,0 +1,259 @@ +import { useState } from "react"; +import { View, Text, FlatList, Pressable, ActivityIndicator } from "react-native"; +import { useRouter } from "expo-router"; +import { GlassButton } from "../components/GlassButton"; +import { useJobs, type ExtendedJobListItem } from "../hooks/useJobs"; +import { X } from "phosphor-react-native"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB", "PB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; +} + +function getJobDisplayName(job: ExtendedJobListItem): string { + if (job.name === "indexer") return "Indexing"; + if (job.name === "thumbnail_generation") return "Generating Thumbnails"; + + if (job.action_context?.action_type) { + const actionType = job.action_context.action_type; + if (actionType === "files.copy") return "Copying Files"; + if (actionType === "files.move") return "Moving Files"; + if (actionType === "files.delete") return "Deleting Files"; + } + + return job.name + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +function getJobSubtext(job: ExtendedJobListItem): string { + if (job.status_message) return job.status_message; + if (job.current_phase) return job.current_phase; + + if (job.generic_progress?.completion) { + const { completed, total, bytes_completed, total_bytes } = + job.generic_progress.completion; + if (bytes_completed && total_bytes) { + return `${formatBytes(bytes_completed)} / ${formatBytes(total_bytes)}`; + } + if (completed && total) { + return `${completed} / ${total} items`; + } + } + + return job.status; +} + +function formatTimeAgo(date: Date): string { + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins}m ago`; + + const diffHours = Math.floor(diffMins / 60); + if (diffHours < 24) return `${diffHours}h ago`; + + const diffDays = Math.floor(diffHours / 24); + return `${diffDays}d ago`; +} + +interface JobCardProps { + job: ExtendedJobListItem; + onPause: (jobId: string) => Promise; + onResume: (jobId: string) => Promise; + onCancel: (jobId: string) => Promise; +} + +function JobCard({ job, onPause, onResume, onCancel }: JobCardProps) { + const isRunning = job.status === "running"; + const isPaused = job.status === "paused"; + const isCompleted = job.status === "completed"; + const isFailed = job.status === "failed"; + const canControl = isRunning || isPaused; + + const statusColor = isRunning + ? "text-accent" + : isPaused + ? "text-yellow-500" + : isCompleted + ? "text-green-500" + : isFailed + ? "text-red-500" + : "text-ink-dull"; + + return ( + + + + + {getJobDisplayName(job)} + + + {getJobSubtext(job)} + + + + + + {job.status} + + {job.started_at && ( + + {isCompleted || isFailed + ? job.completed_at + ? formatTimeAgo(new Date(job.completed_at)) + : formatTimeAgo(new Date(job.started_at)) + : formatTimeAgo(new Date(job.started_at))} + + )} + + + + {/* Progress Bar */} + {job.progress !== null && job.progress !== undefined && (isRunning || isPaused) && ( + + + + )} + + {/* Controls */} + {canControl && ( + + {isRunning && ( + onPause(job.id)} + className="bg-app-darkBox rounded-lg px-4 py-2 flex-1" + > + + Pause + + + )} + {isPaused && ( + onResume(job.id)} + className="bg-accent/20 rounded-lg px-4 py-2 flex-1" + > + + Resume + + + )} + onCancel(job.id)} + className="bg-app-darkBox rounded-lg px-4 py-2" + > + Cancel + + + )} + + ); +} + +type FilterType = "all" | "active" | "completed"; + +export default function JobsScreen() { + const router = useRouter(); + const { jobs, activeJobCount, isLoading, pause, resume, cancel } = useJobs(); + const [filter, setFilter] = useState("active"); + + const filteredJobs = jobs.filter((job) => { + if (filter === "active") { + return job.status === "running" || job.status === "paused"; + } + if (filter === "completed") { + return job.status === "completed" || job.status === "failed"; + } + return true; + }); + + const FilterButton = ({ type, label }: { type: FilterType; label: string }) => ( + setFilter(type)} + className={`px-4 py-2 rounded-full ${ + filter === type ? "bg-accent" : "bg-app-box" + }`} + > + + {label} + + + ); + + return ( + + {/* Header */} + + + + Jobs + + {activeJobCount > 0 + ? `${activeJobCount} active job${activeJobCount !== 1 ? "s" : ""}` + : "No active jobs"} + + + router.back()} + icon={} + /> + + + {/* Filter Tabs */} + + + + + + + + {/* Content */} + {isLoading ? ( + + + + ) : filteredJobs.length === 0 ? ( + + + {filter === "active" + ? "No active jobs" + : filter === "completed" + ? "No completed jobs" + : "No jobs"} + + + ) : ( + item.id} + renderItem={({ item }) => ( + + )} + contentContainerStyle={{ padding: 16 }} + /> + )} + + ); +} diff --git a/apps/mobile/src/app/search.tsx b/apps/mobile/src/app/search.tsx index bab9dcc8e..cc4c84030 100644 --- a/apps/mobile/src/app/search.tsx +++ b/apps/mobile/src/app/search.tsx @@ -1,11 +1,155 @@ -import React from 'react'; -import { View, Text } from 'react-native'; +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { View, Text, ActivityIndicator } from "react-native"; +import { useRouter, useFocusEffect } from "expo-router"; +import { GlassSearchBar } from "../components/GlassSearchBar"; +import { SearchToolbar } from "../components/SearchToolbar"; +import { ListView } from "../screens/explorer/views/ListView"; +import { GridView } from "../screens/explorer/views/GridView"; +import { GlassButton } from "../components/GlassButton"; +import { useSearchStore } from "../screens/explorer/context/SearchContext"; +import { useSearchFiles } from "@sd/ts-client"; +import type { File } from "@sd/ts-client"; +import type { TextInput } from "react-native"; +import { X } from "phosphor-react-native"; + +type SearchViewMode = "list" | "grid"; export default function SearchScreen() { - return ( - - Search - Coming soon - - ); + const router = useRouter(); + const searchInputRef = useRef(null); + const [viewMode, setViewMode] = useState("list"); + + const { query, scope, isSearchMode, enterSearchMode, exitSearchMode, setSearchQuery } = + useSearchStore(); + + // Ref to track current query for cleanup without triggering effect re-runs + const queryRef = useRef(query); + useEffect(() => { + queryRef.current = query; + }, [query]); + + // Get current path from route params if needed (for folder scope) + // For now, we'll use library scope by default + const currentPath = undefined; + + // Fetch search results + const { files, isLoading } = useSearchFiles({ + query, + scope, + currentPath, + enabled: isSearchMode && query.length >= 2, + }); + + // Auto-focus search input on mount + useEffect(() => { + // Focus input after a short delay + const timer = setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + return () => clearTimeout(timer); + }, []); + + // Exit search mode when screen loses focus (user navigated away) + useFocusEffect( + React.useCallback(() => { + return () => { + // Only exit search mode if query is empty when navigating away + // Using ref to avoid stale closure issues when query changes + if (!queryRef.current) { + exitSearchMode(); + } + }; + }, [exitSearchMode]) + ); + + const handleSearchChange = useCallback((value: string) => { + // Update query in store immediately so TextInput shows what user types + setSearchQuery(value); + + if (value.length === 0) { + // Exit search mode when query is cleared, but keep the screen open + // User can manually navigate back if they want to leave + exitSearchMode(); + } else { + // Enter search mode when user starts typing + // The actual search query execution is handled by useSearchFiles which checks query.length >= 2 + enterSearchMode(value, scope); + } + }, [setSearchQuery, exitSearchMode, enterSearchMode, scope]); + + + const handleFilePress = (file: File) => { + // Exit search mode when navigating to a file + exitSearchMode(); + + // If it's a directory, navigate into it + if (file.kind === "Directory") { + router.push({ + pathname: "/explorer", + params: { + type: "path", + path: JSON.stringify(file.sd_path), + }, + }); + } + // TODO: Handle file preview + }; + + return ( + + {/* Header */} + + + + + + router.back()} + icon={} + /> + + + {/* Search Toolbar */} + {isSearchMode && query.length >= 2 && ( + + )} + + + {/* Content */} + {isSearchMode && query.length < 2 ? ( + + + Type at least 2 characters to search + + + ) : isLoading ? ( + + + + ) : files.length === 0 && query.length >= 2 ? ( + + + No results found for "{query}" + + + ) : ( + <> + {viewMode === "list" ? ( + + ) : ( + + )} + + )} + + ); } diff --git a/apps/mobile/src/client/index.ts b/apps/mobile/src/client/index.ts index 59c885e6f..a100b29b2 100644 --- a/apps/mobile/src/client/index.ts +++ b/apps/mobile/src/client/index.ts @@ -13,3 +13,4 @@ export { // Re-export shared hooks from ts-client export { useNormalizedQuery } from "@sd/ts-client/src/hooks/useNormalizedQuery"; +export { useSearchFiles } from "@sd/ts-client"; diff --git a/apps/mobile/src/components/GlassContextMenu.tsx b/apps/mobile/src/components/GlassContextMenu.tsx new file mode 100644 index 000000000..ab5c49464 --- /dev/null +++ b/apps/mobile/src/components/GlassContextMenu.tsx @@ -0,0 +1,142 @@ +import React, { useState, cloneElement, isValidElement } from "react"; +import { View, Text, Pressable, Modal, StyleSheet } from "react-native"; +import { BlurView } from "expo-blur"; +import { + LiquidGlassView, + isLiquidGlassSupported, +} from "@callstack/liquid-glass"; +import type { ReactNode } from "react"; + +export interface MenuItem { + label: string; + onPress: () => void; + icon?: ReactNode; + active?: boolean; +} + +interface GlassContextMenuProps { + trigger: ReactNode; + items: MenuItem[]; + className?: string; +} + +export function GlassContextMenu({ + trigger, + items, + className, +}: GlassContextMenuProps) { + const [visible, setVisible] = useState(false); + + const handleItemPress = (item: MenuItem) => { + item.onPress(); + setVisible(false); + }; + + const menuContent = ( + + {items.map((item, index) => ( + handleItemPress(item)} + className={`px-4 py-3 flex-row items-center gap-3 active:bg-app-hover ${ + item.active ? "bg-accent/10" : "" + }`} + > + {item.icon && ( + + {item.icon} + + )} + + {item.label} + + + ))} + + ); + + const handleTriggerPress = () => setVisible(true); + + const triggerElement = isValidElement(trigger) + ? cloneElement(trigger as React.ReactElement, { + onPress: handleTriggerPress, + className: className, + }) + : ( + + {trigger} + + ); + + return ( + <> + {triggerElement} + + setVisible(false)} + > + setVisible(false)} + className="bg-black/50" + > + + + + {isLiquidGlassSupported ? ( + + + {menuContent} + + + ) : ( + + + + {menuContent} + + + )} + + + ); +} + +GlassContextMenu.displayName = "GlassContextMenu"; diff --git a/apps/mobile/src/components/GlassSearchBar.tsx b/apps/mobile/src/components/GlassSearchBar.tsx index 6d5db2cf0..73ea381a5 100644 --- a/apps/mobile/src/components/GlassSearchBar.tsx +++ b/apps/mobile/src/components/GlassSearchBar.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef } from "react"; +import React, { forwardRef, useState, useEffect, useCallback } from "react"; import { TextInput, View, Pressable, type TextInputProps } from "react-native"; import { BlurView } from "expo-blur"; import { @@ -6,45 +6,131 @@ import { isLiquidGlassSupported, } from "@callstack/liquid-glass"; import { useRouter } from "expo-router"; -import { MagnifyingGlass } from "phosphor-react-native"; +import { MagnifyingGlass, X } from "phosphor-react-native"; -interface GlassSearchBarProps extends Omit { +interface GlassSearchBarProps extends Omit { onPress?: () => void; + onChange?: (value: string) => void; + value?: string; className?: string; interactive?: boolean; + debounceMs?: number; } export const GlassSearchBar = forwardRef( - ({ onPress, className, interactive = true, editable, ...textInputProps }, ref) => { + ( + { + onPress, + onChange, + value: controlledValue, + className, + interactive = true, + editable = true, + debounceMs = 300, + ...textInputProps + }, + ref, + ) => { const router = useRouter(); + const [internalValue, setInternalValue] = useState(""); + const [debounceTimer, setDebounceTimer] = useState | null>( + null, + ); - const handlePress = () => { + const isControlled = controlledValue !== undefined; + const currentValue = isControlled ? controlledValue : internalValue; + + const handleChange = useCallback( + (text: string) => { + // For controlled inputs, always call onChange immediately + // The parent component handles the state update + if (isControlled) { + if (onChange) { + onChange(text); + } + return; + } + + // For uncontrolled inputs, update internal state immediately + setInternalValue(text); + + // Clear existing timer + if (debounceTimer) { + clearTimeout(debounceTimer); + } + + // Set new debounced timer only for uncontrolled inputs + if (onChange) { + const timer = setTimeout(() => { + onChange(text); + }, debounceMs); + setDebounceTimer(timer); + } + }, + [isControlled, onChange, debounceMs, debounceTimer], + ); + + const handleClear = useCallback(() => { + if (!isControlled) { + setInternalValue(""); + } + if (onChange) { + onChange(""); + } + if (debounceTimer) { + clearTimeout(debounceTimer); + setDebounceTimer(null); + } + }, [isControlled, onChange, debounceTimer]); + + const handlePress = useCallback(() => { if (onPress) { onPress(); - } else { + } else if (!editable) { router.push("/search"); } - }; + }, [onPress, editable, router]); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + } + }; + }, [debounceTimer]); const content = ( - + + {currentValue.length > 0 && editable && ( + + + + )} ); if (isLiquidGlassSupported) { return ( @@ -67,7 +153,7 @@ export const GlassSearchBar = forwardRef( // Fallback for older iOS and Android return ( ( ); - } + }, ); GlassSearchBar.displayName = "GlassSearchBar"; diff --git a/apps/mobile/src/components/SearchToolbar.tsx b/apps/mobile/src/components/SearchToolbar.tsx new file mode 100644 index 000000000..e7afaef93 --- /dev/null +++ b/apps/mobile/src/components/SearchToolbar.tsx @@ -0,0 +1,135 @@ +import type React from "react"; +import { View, Text, Pressable } from "react-native"; +import { FunnelSimple, List } from "phosphor-react-native"; +import { useSearchStore } from "../screens/explorer/context/SearchContext"; +import type { SearchScope } from "../screens/explorer/context/SearchContext"; +import { GlassButton } from "./GlassButton"; +import { GlassContextMenu } from "./GlassContextMenu"; + +interface ScopeButtonProps { + active: boolean; + onPress: () => void; + children: React.ReactNode; +} + +function ScopeButton({ active, onPress, children }: ScopeButtonProps) { + return ( + + + {children} + + + ); +} + +interface SearchToolbarProps { + viewMode?: "list" | "grid"; + setViewMode?: (mode: "list" | "grid") => void; +} + +export function SearchToolbar({ viewMode, setViewMode }: SearchToolbarProps = {}) { + const { scope, setSearchScope } = useSearchStore(); + + const handleScopeChange = (newScope: SearchScope) => { + setSearchScope(newScope); + }; + + return ( + + + Search in: + + handleScopeChange("folder")} + > + This Folder + + handleScopeChange("location")} + > + Location + + handleScopeChange("library")} + > + Library + + + + + + + + {viewMode && setViewMode && ( + setViewMode("list"), + icon: ( + + ), + active: viewMode === "list", + }, + { + label: "Grid View", + onPress: () => setViewMode("grid"), + icon: ( + + ⊞ + + ), + active: viewMode === "grid", + }, + ]} + trigger={ + + ⋯ + + } + size={32} + /> + } + /> + )} + + + } + size={32} + /> + + + ); +} diff --git a/apps/mobile/src/screens/browse/BrowseScreen.tsx b/apps/mobile/src/screens/browse/BrowseScreen.tsx index 2d736a2af..6d201dc75 100644 --- a/apps/mobile/src/screens/browse/BrowseScreen.tsx +++ b/apps/mobile/src/screens/browse/BrowseScreen.tsx @@ -18,6 +18,7 @@ import Animated, { import { useNormalizedQuery } from "../../client"; import { PageIndicator } from "../../components/PageIndicator"; import { GlassSearchBar } from "../../components/GlassSearchBar"; +import { useRouter } from "expo-router"; import sharedColors from "@sd/ui/style/colors"; import type { SpaceItem, SpaceGroup } from "@sd/ts-client"; import { SpaceItem as SpaceItemComponent, SpaceGroupComponent } from "./components"; @@ -38,6 +39,7 @@ function SpaceContent({ space: Space; insets: EdgeInsets; }) { + const router = useRouter(); const scrollY = useSharedValue(0); const scrollHandler = useAnimatedScrollHandler({ @@ -46,6 +48,10 @@ function SpaceContent({ }, }); + const handleSearchPress = () => { + router.push("/search"); + }; + // Fetch space layout const { data: layout } = useNormalizedQuery({ query: "spaces.get_layout", @@ -106,7 +112,7 @@ function SpaceContent({ {/* Search Bar */} - + {/* Space Items (pinned shortcuts) */} diff --git a/apps/mobile/src/screens/explorer/ExplorerScreen.tsx b/apps/mobile/src/screens/explorer/ExplorerScreen.tsx index 1a0431f3d..d924f3ddf 100644 --- a/apps/mobile/src/screens/explorer/ExplorerScreen.tsx +++ b/apps/mobile/src/screens/explorer/ExplorerScreen.tsx @@ -7,6 +7,11 @@ import { ListView } from "./views/ListView"; import { GridView } from "./views/GridView"; import type { Device } from "@sd/ts-client"; import { useNormalizedQuery } from "../../client"; +import { GlassButton } from "../../components/GlassButton"; +import { GlassContextMenu } from "../../components/GlassContextMenu"; +import { SearchToolbar } from "../../components/SearchToolbar"; +import { useSearchStore } from "./context/SearchContext"; +import { List, ArrowLeft } from "phosphor-react-native"; type ViewMode = "list" | "grid"; @@ -20,6 +25,7 @@ export function ExplorerScreen() { id?: string; }>(); const [viewMode, setViewMode] = useState("list"); + const { isSearchMode, query } = useSearchStore(); // Parse params into the format expected by hooks const params = useMemo(() => { @@ -103,52 +109,75 @@ export function ExplorerScreen() { className="bg-app-box border-b border-app-line" style={{ paddingTop: insets.top }} > - + {/* Back button */} - router.back()} - className="w-10 h-10 items-center justify-center -ml-2" - > - - + icon={ + + } + /> - {/* Title */} - + {/* Title - absolutely centered */} + {title} - {/* View mode switcher */} - - setViewMode("list")} - className={`w-10 h-10 items-center justify-center rounded-md ${ - viewMode === "list" ? "bg-accent/10" : "" - }`} - > - setViewMode("list"), + icon: ( + + ), + active: viewMode === "list", + }, + { + label: "Grid View", + onPress: () => setViewMode("grid"), + icon: ( + + ⊞ + + ), + active: viewMode === "grid", + }, + ]} + trigger={ + + ⋯ + } - > - ≡ - - - setViewMode("grid")} - className={`w-10 h-10 items-center justify-center rounded-md ${ - viewMode === "grid" ? "bg-accent/10" : "" - }`} - > - - ⊞ - - - + /> + } + /> + + {/* Search Toolbar */} + {isSearchMode && query.length >= 2 && } {/* Content */} diff --git a/apps/mobile/src/screens/explorer/context/SearchContext.tsx b/apps/mobile/src/screens/explorer/context/SearchContext.tsx new file mode 100644 index 000000000..8b528f559 --- /dev/null +++ b/apps/mobile/src/screens/explorer/context/SearchContext.tsx @@ -0,0 +1,62 @@ +import { create } from "zustand"; + +export type SearchScope = "folder" | "location" | "library"; + +export interface SearchFilters { + fileTypes?: string[]; + contentTypes?: string[]; + sizeMin?: number; + sizeMax?: number; + dateModifiedStart?: Date; + dateModifiedEnd?: Date; + tags?: string[]; +} + +interface SearchStore { + // Search mode state + isSearchMode: boolean; + query: string; + scope: SearchScope; + filters: SearchFilters; + + // Actions + enterSearchMode: (query: string, scope?: SearchScope) => void; + exitSearchMode: () => void; + setSearchQuery: (query: string) => void; + setSearchScope: (scope: SearchScope) => void; + setSearchFilters: (filters: SearchFilters) => void; +} + +export const useSearchStore = create((set) => ({ + // Initial state + isSearchMode: false, + query: "", + scope: "library", + filters: {}, + + // Actions + enterSearchMode: (query, scope = "library") => + set({ + isSearchMode: true, + query, + scope, + }), + + exitSearchMode: () => + set({ + isSearchMode: false, + query: "", + filters: {}, + }), + + setSearchQuery: (query) => + set((state) => ({ + query, + // Auto-exit if query is cleared + isSearchMode: query.length > 0 ? state.isSearchMode : false, + })), + + setSearchScope: (scope) => set({ scope }), + + setSearchFilters: (filters) => set({ filters }), +})); diff --git a/apps/mobile/src/screens/explorer/hooks/useExplorerFiles.ts b/apps/mobile/src/screens/explorer/hooks/useExplorerFiles.ts index ca1f01d9d..ae9bec518 100644 --- a/apps/mobile/src/screens/explorer/hooks/useExplorerFiles.ts +++ b/apps/mobile/src/screens/explorer/hooks/useExplorerFiles.ts @@ -1,9 +1,10 @@ import { useMemo } from "react"; import type { File, SdPath } from "@sd/ts-client"; -import { useNormalizedQuery } from "../../../client"; +import { useNormalizedQuery, useSearchFiles } from "@sd/ts-client"; import { useVirtualListing } from "./useVirtualListing"; +import { useSearchStore } from "../context/SearchContext"; -export type FileSource = "virtual" | "directory"; +export type FileSource = "search" | "virtual" | "directory"; export interface ExplorerFilesResult { files: File[]; @@ -14,9 +15,10 @@ export interface ExplorerFilesResult { /** * Centralized hook for fetching files in the mobile explorer. * - * Handles two file sources with priority: - * 1. Virtual listings (devices/volumes/locations) - * 2. Directory listings (normal file browsing) + * Handles three file sources with priority: + * 1. Search results (when in search mode) + * 2. Virtual listings (devices/volumes/locations) + * 3. Directory listings (normal file browsing) */ export function useExplorerFiles( params: @@ -24,8 +26,11 @@ export function useExplorerFiles( | { type: "view"; view: string; id?: string } | undefined, ): ExplorerFilesResult { + const { isSearchMode, query, scope } = useSearchStore(); + // Check for virtual listing first - const { files: virtualFiles, isVirtualView, isLoading: virtualLoading } = useVirtualListing(params); + const { files: virtualFiles, isVirtualView, isLoading: virtualLoading } = + useVirtualListing(params); // Parse path for directory listing const currentPath: SdPath | null = useMemo(() => { @@ -40,6 +45,14 @@ export function useExplorerFiles( return null; }, [params]); + // Search query + const { files: searchFiles, isLoading: searchLoading } = useSearchFiles({ + query, + scope, + currentPath: scope === "folder" && currentPath ? currentPath : undefined, + enabled: isSearchMode && query.length >= 2, + }); + // Directory query const directoryQuery = useNormalizedQuery({ query: "files.directory_listing", @@ -51,34 +64,44 @@ export function useExplorerFiles( sort_by: "name", // Default to name sorting folders_first: true, } - : null!, + : (null as any), resourceType: "file", - enabled: !!currentPath && !isVirtualView, + enabled: !!currentPath && !isVirtualView && !isSearchMode, pathScope: currentPath ?? undefined, }); - console.log("[useExplorerFiles] Query state:", { - isVirtualView, - hasPath: !!currentPath, - enabled: !!currentPath && !isVirtualView, - isLoading: directoryQuery.isLoading, - hasData: !!directoryQuery.data, - fileCount: (directoryQuery.data as any)?.files?.length || 0, - }); - - // Determine source and files with priority: virtual > directory - const source: FileSource = isVirtualView ? "virtual" : "directory"; + // Determine source and files with priority: search > virtual > directory + const source: FileSource = isSearchMode + ? "search" + : isVirtualView + ? "virtual" + : "directory"; const files = useMemo(() => { + if (isSearchMode) { + return searchFiles || []; + } if (isVirtualView) { return virtualFiles || []; } - return (directoryQuery.data as any)?.files || []; - }, [isVirtualView, virtualFiles, directoryQuery.data]); + return (directoryQuery.data as { files?: File[] })?.files || []; + }, [ + isSearchMode, + isVirtualView, + searchFiles, + virtualFiles, + directoryQuery.data, + ]); + + const isLoading = isSearchMode + ? searchLoading + : isVirtualView + ? virtualLoading + : directoryQuery.isLoading; return { files, - isLoading: isVirtualView ? virtualLoading : directoryQuery.isLoading, + isLoading, source, }; } diff --git a/apps/mobile/src/screens/overview/OverviewScreen.tsx b/apps/mobile/src/screens/overview/OverviewScreen.tsx index 4df620f34..35cd6f117 100644 --- a/apps/mobile/src/screens/overview/OverviewScreen.tsx +++ b/apps/mobile/src/screens/overview/OverviewScreen.tsx @@ -9,6 +9,7 @@ import Animated, { interpolate, Extrapolation, withTiming, + withRepeat, Easing, } from "react-native-reanimated"; import { BlurView } from "expo-blur"; @@ -20,6 +21,10 @@ import { LibrarySwitcherPanel } from "../../components/LibrarySwitcherPanel"; import { GlassButton } from "../../components/GlassButton"; import { GlassSearchBar } from "../../components/GlassSearchBar"; import { JobManagerPanel } from "../../components/JobManagerPanel"; +import { useRouter } from "expo-router"; +import { useSearchStore } from "../explorer/context/SearchContext"; +import { CircleNotch, ListBullets } from "phosphor-react-native"; +import { useJobs } from "../../hooks/useJobs"; const HEADER_INITIAL_HEIGHT = 40; const HERO_HEIGHT = 430 + HEADER_INITIAL_HEIGHT; @@ -29,6 +34,7 @@ const NETWORK_HEADER_HEIGHT = 50; export function OverviewScreen() { const insets = useSafeAreaInsets(); const navigation = useNavigation(); + const router = useRouter(); const scrollY = useSharedValue(0); const expandedOffsetY = useSharedValue(0); const [showPairing, setShowPairing] = useState(false); @@ -36,6 +42,35 @@ export function OverviewScreen() { const [selectedLocationId, setSelectedLocationId] = useState( null ); + const { enterSearchMode } = useSearchStore(); + const { activeJobCount, hasRunningJobs } = useJobs(); + + // Spinning animation for jobs icon + const spinRotation = useSharedValue(0); + + useEffect(() => { + if (hasRunningJobs) { + spinRotation.value = withRepeat( + withTiming(360, { duration: 1000, easing: Easing.linear }), + -1, // infinite + false // don't reverse + ); + } else { + spinRotation.value = withTiming(0, { duration: 200 }); + } + }, [hasRunningJobs, spinRotation]); + + const spinStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${spinRotation.value}deg` }], + })); + + const handleSearchPress = () => { + router.push("/search"); + }; + + const handleJobsPress = () => { + router.push("/jobs"); + }; // Fetch library info with real-time statistics updates const { @@ -313,6 +348,37 @@ export function OverviewScreen() { > {libraryInfo.name} + + {hasRunningJobs ? ( + + + + ) : ( + + )} + {activeJobCount > 0 && ( + + + {activeJobCount > 9 ? "9+" : activeJobCount} + + + )} + + } + /> ⋯ @@ -322,7 +388,7 @@ export function OverviewScreen() { {/* Search Bar */} - + {/* Wrapper to elevate HeroStats above ScrollView for touch events */} diff --git a/packages/ts-client/src/hooks/index.ts b/packages/ts-client/src/hooks/index.ts index 280c297a2..981c8521b 100644 --- a/packages/ts-client/src/hooks/index.ts +++ b/packages/ts-client/src/hooks/index.ts @@ -8,3 +8,4 @@ export { useNormalizedQuery } from "./useNormalizedQuery"; // Alias for backwards compatibility export { useNormalizedQuery as useNormalizedCache } from "./useNormalizedQuery"; export { useJobs, type UseJobsOptions, type UseJobsReturn, type SpeedSample, type ExtendedJobListItem } from "./useJobs"; +export { useSearchFiles, type UseSearchFilesOptions, type UseSearchFilesReturn, type SearchScopeUI } from "./useSearchFiles"; diff --git a/packages/ts-client/src/hooks/useSearchFiles.ts b/packages/ts-client/src/hooks/useSearchFiles.ts new file mode 100644 index 000000000..9d11522de --- /dev/null +++ b/packages/ts-client/src/hooks/useSearchFiles.ts @@ -0,0 +1,149 @@ +import { useMemo } from "react"; +import type { + File, + FileSearchInput, + FileSearchOutput, + SearchScope as TSSearchScope, + SdPath, +} from "../generated/types"; +import { useNormalizedQuery } from "./useNormalizedQuery"; + +export type SearchScopeUI = "folder" | "location" | "library"; + +export interface UseSearchFilesOptions { + /** Search query string (minimum 2 characters to search) */ + query: string; + /** Search scope: "folder" (current path), "location", or "library" */ + scope: SearchScopeUI; + /** Current path (required for "folder" scope) */ + currentPath?: SdPath | null; + /** Location ID (required for "location" scope) */ + locationId?: string | null; + /** Sort field: "Relevance", "Name", "Size", "ModifiedAt", "CreatedAt" */ + sortBy?: "Relevance" | "Name" | "Size" | "ModifiedAt" | "CreatedAt"; + /** Sort direction */ + sortDirection?: "Asc" | "Desc"; + /** Search mode */ + mode?: "Fast" | "Normal" | "Full"; + /** Search filters */ + filters?: { + file_types?: string[] | null; + tags?: { include: string[]; exclude: string[] } | null; + date_range?: { + field: "CreatedAt" | "ModifiedAt" | "AccessedAt" | "IndexedAt"; + start?: Date | null; + end?: Date | null; + } | null; + size_range?: { min?: number | null; max?: number | null } | null; + locations?: string[] | null; + content_types?: string[] | null; + include_hidden?: boolean | null; + include_archived?: boolean | null; + }; + /** Pagination limit */ + limit?: number; + /** Whether query is enabled */ + enabled?: boolean; +} + +export interface UseSearchFilesReturn { + /** Search results */ + files: File[]; + /** Loading state */ + isLoading: boolean; + /** Error state */ + error: Error | null; +} + +/** + * Shared search hook for fetching files via search.files query. + * Platform-agnostic - can be used by both desktop and mobile. + * + * @example + * ```tsx + * const { files, isLoading } = useSearchFiles({ + * query: "photos", + * scope: "library", + * sortBy: "Relevance", + * }); + * ``` + */ +export function useSearchFiles( + options: UseSearchFilesOptions, +): UseSearchFilesReturn { + const { + query, + scope, + currentPath, + locationId, + sortBy = "Relevance", + sortDirection = "Desc", + mode = "Normal", + filters, + limit = 1000, + enabled = true, + } = options; + + // Map scope to TS SearchScope type + const tsScope: TSSearchScope = useMemo(() => { + if (scope === "folder" && currentPath) { + return { Path: { path: currentPath } }; + } + if (scope === "location" && locationId) { + return { Location: { location_id: locationId } }; + } + return "Library"; + }, [scope, currentPath, locationId]); + + // Build search input + const searchInput: FileSearchInput = useMemo( + () => ({ + query, + scope: tsScope, + mode, + filters: { + file_types: filters?.file_types ?? null, + tags: filters?.tags ?? null, + date_range: filters?.date_range ?? null, + size_range: filters?.size_range ?? null, + locations: filters?.locations ?? null, + content_types: filters?.content_types ?? null, + include_hidden: filters?.include_hidden ?? null, + include_archived: filters?.include_archived ?? null, + }, + sort: { + field: sortBy, + direction: sortDirection, + }, + pagination: { + limit, + offset: 0, + }, + }), + [ + query, + tsScope, + mode, + filters, + sortBy, + sortDirection, + limit, + ], + ); + + // Execute search query + const searchQuery = useNormalizedQuery({ + query: "search.files", + input: searchInput, + resourceType: "file", + pathScope: scope === "folder" && currentPath ? currentPath : undefined, + enabled: enabled && query.length >= 2, + }); + + const files = + (searchQuery.data as FileSearchOutput | undefined)?.files || []; + const isLoading = searchQuery.isLoading; + const error = searchQuery.error; + + return { files, isLoading, error }; +}