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.
This commit is contained in:
Jamie Pine
2026-01-22 12:30:01 -08:00
parent 60803ab7ec
commit 25197fde6d
14 changed files with 1199 additions and 89 deletions

View File

@@ -27,6 +27,13 @@ export default function RootLayout() {
animation: 'slide_from_bottom'
}}
/>
<Stack.Screen
name="jobs"
options={{
presentation: 'modal',
animation: 'slide_from_bottom'
}}
/>
</Stack>
</SpacedriveProvider>
</AppResetContext.Provider>

View File

@@ -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<void>;
onResume: (jobId: string) => Promise<void>;
onCancel: (jobId: string) => Promise<void>;
}
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 (
<View className="bg-app-box border border-app-line rounded-xl p-4 mb-3">
<View className="flex-row items-start justify-between mb-2">
<View className="flex-1 mr-3">
<Text className="text-ink text-base font-semibold" numberOfLines={1}>
{getJobDisplayName(job)}
</Text>
<Text className="text-ink-dull text-sm mt-1" numberOfLines={2}>
{getJobSubtext(job)}
</Text>
</View>
<View className="items-end">
<Text className={`text-xs font-medium capitalize ${statusColor}`}>
{job.status}
</Text>
{job.started_at && (
<Text className="text-ink-faint text-xs mt-1">
{isCompleted || isFailed
? job.completed_at
? formatTimeAgo(new Date(job.completed_at))
: formatTimeAgo(new Date(job.started_at))
: formatTimeAgo(new Date(job.started_at))}
</Text>
)}
</View>
</View>
{/* Progress Bar */}
{job.progress !== null && job.progress !== undefined && (isRunning || isPaused) && (
<View className="bg-app-darkBox h-2 rounded-full overflow-hidden mb-3">
<View
className={`h-full rounded-full ${isPaused ? "bg-yellow-500" : "bg-accent"}`}
style={{ width: `${Math.min(100, Math.max(0, job.progress))}%` }}
/>
</View>
)}
{/* Controls */}
{canControl && (
<View className="flex-row gap-2 mt-1">
{isRunning && (
<Pressable
onPress={() => onPause(job.id)}
className="bg-app-darkBox rounded-lg px-4 py-2 flex-1"
>
<Text className="text-ink-dull text-sm font-medium text-center">
Pause
</Text>
</Pressable>
)}
{isPaused && (
<Pressable
onPress={() => onResume(job.id)}
className="bg-accent/20 rounded-lg px-4 py-2 flex-1"
>
<Text className="text-accent text-sm font-medium text-center">
Resume
</Text>
</Pressable>
)}
<Pressable
onPress={() => onCancel(job.id)}
className="bg-app-darkBox rounded-lg px-4 py-2"
>
<Text className="text-red-400 text-sm font-medium">Cancel</Text>
</Pressable>
</View>
)}
</View>
);
}
type FilterType = "all" | "active" | "completed";
export default function JobsScreen() {
const router = useRouter();
const { jobs, activeJobCount, isLoading, pause, resume, cancel } = useJobs();
const [filter, setFilter] = useState<FilterType>("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 }) => (
<Pressable
onPress={() => setFilter(type)}
className={`px-4 py-2 rounded-full ${
filter === type ? "bg-accent" : "bg-app-box"
}`}
>
<Text
className={`text-sm font-medium ${
filter === type ? "text-white" : "text-ink-dull"
}`}
>
{label}
</Text>
</Pressable>
);
return (
<View className="flex-1 bg-app">
{/* Header */}
<View
className="bg-app-box border-b border-app-line"
style={{ paddingTop: 18 }}
>
<View className="px-4 pb-4 flex-row items-center gap-3">
<View className="flex-1">
<Text className="text-ink text-xl font-bold">Jobs</Text>
<Text className="text-ink-dull text-sm mt-0.5">
{activeJobCount > 0
? `${activeJobCount} active job${activeJobCount !== 1 ? "s" : ""}`
: "No active jobs"}
</Text>
</View>
<GlassButton
onPress={() => router.back()}
icon={<X size={20} color="hsl(235, 10%, 55%)" weight="bold" />}
/>
</View>
{/* Filter Tabs */}
<View className="flex-row gap-2 px-4 pb-3">
<FilterButton type="active" label="Active" />
<FilterButton type="completed" label="History" />
<FilterButton type="all" label="All" />
</View>
</View>
{/* Content */}
{isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="hsl(208, 100%, 57%)" />
</View>
) : filteredJobs.length === 0 ? (
<View className="flex-1 items-center justify-center p-8">
<Text className="text-ink-dull text-sm text-center">
{filter === "active"
? "No active jobs"
: filter === "completed"
? "No completed jobs"
: "No jobs"}
</Text>
</View>
) : (
<FlatList
data={filteredJobs}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<JobCard
job={item}
onPause={pause}
onResume={resume}
onCancel={cancel}
/>
)}
contentContainerStyle={{ padding: 16 }}
/>
)}
</View>
);
}

View File

@@ -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 (
<View className="flex-1 bg-app-modal items-center justify-center">
<Text className="text-ink text-xl">Search</Text>
<Text className="text-ink-dull text-sm mt-2">Coming soon</Text>
</View>
);
const router = useRouter();
const searchInputRef = useRef<TextInput>(null);
const [viewMode, setViewMode] = useState<SearchViewMode>("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 (
<View className="flex-1 bg-app">
{/* Header */}
<View
className="bg-app-box border-b border-app-line"
style={{ paddingTop: 18 }}
>
<View className="px-4 pb-3 flex-row items-center gap-3">
<View className="flex-1">
<GlassSearchBar
ref={searchInputRef}
value={query}
onChange={handleSearchChange}
editable={true}
autoFocus
/>
</View>
<GlassButton
onPress={() => router.back()}
icon={<X size={20} color="hsl(235, 10%, 55%)" weight="bold" />}
/>
</View>
{/* Search Toolbar */}
{isSearchMode && query.length >= 2 && (
<SearchToolbar viewMode={viewMode} setViewMode={setViewMode} />
)}
</View>
{/* Content */}
{isSearchMode && query.length < 2 ? (
<View className="flex-1 items-center justify-center p-8">
<Text className="text-ink-dull text-sm text-center">
Type at least 2 characters to search
</Text>
</View>
) : isLoading ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator size="large" color="hsl(208, 100%, 57%)" />
</View>
) : files.length === 0 && query.length >= 2 ? (
<View className="flex-1 items-center justify-center p-8">
<Text className="text-ink-dull text-sm text-center">
No results found for "{query}"
</Text>
</View>
) : (
<>
{viewMode === "list" ? (
<ListView files={files} onFilePress={handleFilePress} />
) : (
<GridView files={files} onFilePress={handleFilePress} />
)}
</>
)}
</View>
);
}

View File

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

View File

@@ -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 = (
<View className="py-2 min-w-[180]">
{items.map((item, index) => (
<Pressable
key={index}
onPress={() => handleItemPress(item)}
className={`px-4 py-3 flex-row items-center gap-3 active:bg-app-hover ${
item.active ? "bg-accent/10" : ""
}`}
>
{item.icon && (
<View className="w-5 h-5 items-center justify-center">
{item.icon}
</View>
)}
<Text
className={`flex-1 text-ink ${item.active ? "text-accent font-medium" : ""}`}
>
{item.label}
</Text>
</Pressable>
))}
</View>
);
const handleTriggerPress = () => setVisible(true);
const triggerElement = isValidElement(trigger)
? cloneElement(trigger as React.ReactElement<any>, {
onPress: handleTriggerPress,
className: className,
})
: (
<Pressable onPress={handleTriggerPress} className={className}>
{trigger}
</Pressable>
);
return (
<>
{triggerElement}
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={() => setVisible(false)}
>
<Pressable
style={StyleSheet.absoluteFill}
onPress={() => setVisible(false)}
className="bg-black/50"
>
<View className="flex-1" />
</Pressable>
{isLiquidGlassSupported ? (
<View
style={{
position: "absolute",
top: 60,
right: 16,
}}
>
<LiquidGlassView
interactive
effect="regular"
colorScheme="dark"
style={{
borderRadius: 16,
overflow: "hidden",
minWidth: 180,
}}
>
{menuContent}
</LiquidGlassView>
</View>
) : (
<View
style={{
position: "absolute",
top: 60,
right: 16,
borderRadius: 16,
overflow: "hidden",
minWidth: 180,
}}
>
<BlurView
intensity={80}
tint="dark"
style={{
borderWidth: 1,
borderColor: "rgba(128, 128, 128, 0.3)",
borderRadius: 16,
}}
>
<View className="absolute inset-0 bg-app-box/20" />
{menuContent}
</BlurView>
</View>
)}
</Modal>
</>
);
}
GlassContextMenu.displayName = "GlassContextMenu";

View File

@@ -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<TextInputProps, 'style'> {
interface GlassSearchBarProps extends Omit<TextInputProps, 'style' | 'onChange' | 'onChangeText'> {
onPress?: () => void;
onChange?: (value: string) => void;
value?: string;
className?: string;
interactive?: boolean;
debounceMs?: number;
}
export const GlassSearchBar = forwardRef<TextInput, GlassSearchBarProps>(
({ 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<ReturnType<typeof setTimeout> | 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 = (
<View className="flex-1 px-4 flex-row items-center gap-3">
<MagnifyingGlass size={20} color="hsl(235, 10%, 55%)" weight="bold" />
<TextInput
ref={ref}
editable={editable ?? false}
placeholder="Search library"
placeholderTextColor="hsl(235, 10%, 55%)"
className="flex-1 text-ink text-base text-md"
cursorColor="hsl(220, 90%, 56%)"
{...textInputProps}
/>
<TextInput
ref={ref}
editable={editable}
pointerEvents={editable ? "auto" : "none"}
value={currentValue}
onChangeText={handleChange}
placeholder="Search library"
placeholderTextColor="hsl(235, 10%, 55%)"
className="flex-1 text-ink text-base text-md"
cursorColor="hsl(220, 90%, 56%)"
{...textInputProps}
/>
{currentValue.length > 0 && editable && (
<Pressable
onPress={handleClear}
className="p-1 -mr-1"
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<X size={18} color="hsl(235, 10%, 55%)" weight="bold" />
</Pressable>
)}
</View>
);
if (isLiquidGlassSupported) {
return (
<Pressable
onPress={handlePress}
onPress={editable ? undefined : handlePress}
className={className}
style={{ opacity: 1 }}
>
@@ -67,7 +153,7 @@ export const GlassSearchBar = forwardRef<TextInput, GlassSearchBarProps>(
// Fallback for older iOS and Android
return (
<Pressable
onPress={handlePress}
onPress={editable ? undefined : handlePress}
className={`overflow-hidden ${className || ""}`}
style={{
height: 48,
@@ -89,7 +175,7 @@ export const GlassSearchBar = forwardRef<TextInput, GlassSearchBarProps>(
</BlurView>
</Pressable>
);
}
},
);
GlassSearchBar.displayName = "GlassSearchBar";

View File

@@ -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 (
<Pressable
onPress={onPress}
className={`px-3 py-1.5 rounded-md ${
active ? "bg-accent" : "bg-app-box/30"
}`}
>
<Text
className={`text-xs font-medium ${
active ? "text-white" : "text-ink-dull"
}`}
>
{children}
</Text>
</Pressable>
);
}
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 (
<View className="flex-row items-center gap-3 px-4 py-2 border-b border-app-line bg-app-box/50">
<View className="flex-row items-center gap-2">
<Text className="text-xs font-medium text-ink-dull">Search in:</Text>
<View className="flex-row items-center gap-1">
<ScopeButton
active={scope === "folder"}
onPress={() => handleScopeChange("folder")}
>
This Folder
</ScopeButton>
<ScopeButton
active={scope === "location"}
onPress={() => handleScopeChange("location")}
>
Location
</ScopeButton>
<ScopeButton
active={scope === "library"}
onPress={() => handleScopeChange("library")}
>
Library
</ScopeButton>
</View>
</View>
<View className="h-4 w-px bg-app-line" />
<View className="flex-1" />
{viewMode && setViewMode && (
<GlassContextMenu
items={[
{
label: "List View",
onPress: () => setViewMode("list"),
icon: (
<List
size={20}
color={
viewMode === "list"
? "hsl(208, 100%, 57%)"
: "hsl(235, 10%, 55%)"
}
weight="bold"
/>
),
active: viewMode === "list",
},
{
label: "Grid View",
onPress: () => setViewMode("grid"),
icon: (
<Text
style={{
fontSize: 20,
color:
viewMode === "grid"
? "hsl(208, 100%, 57%)"
: "hsl(235, 10%, 55%)",
}}
>
</Text>
),
active: viewMode === "grid",
},
]}
trigger={
<GlassButton
icon={
<Text className="text-ink-dull" style={{ fontSize: 18 }}>
</Text>
}
size={32}
/>
}
/>
)}
<GlassButton
icon={
<FunnelSimple size={18} color="hsl(235, 10%, 55%)" weight="bold" />
}
size={32}
/>
</View>
);
}

View File

@@ -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 */}
<View className="mb-6">
<GlassSearchBar />
<GlassSearchBar onPress={handleSearchPress} editable={false} />
</View>
{/* Space Items (pinned shortcuts) */}

View File

@@ -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<ViewMode>("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 }}
>
<View className="flex-row items-center justify-between px-4 h-14">
<View className="relative flex-row items-center justify-between px-4 h-14">
{/* Back button */}
<Pressable
<GlassButton
onPress={() => router.back()}
className="w-10 h-10 items-center justify-center -ml-2"
>
<Text className="text-ink text-xl"></Text>
</Pressable>
icon={
<ArrowLeft size={20} color="hsl(235, 10%, 55%)" weight="bold" />
}
/>
{/* Title */}
<Text className="text-ink font-semibold text-lg flex-1 text-center">
{/* Title - absolutely centered */}
<Text
className="absolute left-0 right-0 text-ink font-semibold text-lg text-center"
pointerEvents="none"
>
{title}
</Text>
{/* View mode switcher */}
<View className="flex-row gap-1">
<Pressable
onPress={() => setViewMode("list")}
className={`w-10 h-10 items-center justify-center rounded-md ${
viewMode === "list" ? "bg-accent/10" : ""
}`}
>
<Text
className={
viewMode === "list" ? "text-accent" : "text-ink-dull"
{/* View mode menu */}
<GlassContextMenu
items={[
{
label: "List View",
onPress: () => setViewMode("list"),
icon: (
<List
size={20}
color={
viewMode === "list"
? "hsl(208, 100%, 57%)"
: "hsl(235, 10%, 55%)"
}
weight="bold"
/>
),
active: viewMode === "list",
},
{
label: "Grid View",
onPress: () => setViewMode("grid"),
icon: (
<Text
style={{
fontSize: 20,
color:
viewMode === "grid"
? "hsl(208, 100%, 57%)"
: "hsl(235, 10%, 55%)",
}}
>
</Text>
),
active: viewMode === "grid",
},
]}
trigger={
<GlassButton
icon={
<Text className="text-ink-dull" style={{ fontSize: 18 }}>
</Text>
}
>
</Text>
</Pressable>
<Pressable
onPress={() => setViewMode("grid")}
className={`w-10 h-10 items-center justify-center rounded-md ${
viewMode === "grid" ? "bg-accent/10" : ""
}`}
>
<Text
className={
viewMode === "grid" ? "text-accent" : "text-ink-dull"
}
>
</Text>
</Pressable>
</View>
/>
}
/>
</View>
{/* Search Toolbar */}
{isSearchMode && query.length >= 2 && <SearchToolbar />}
</View>
{/* Content */}

View File

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

View File

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

View File

@@ -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<string | null>(
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}
</Animated.Text>
<GlassButton
onPress={handleJobsPress}
icon={
<View>
{hasRunningJobs ? (
<Animated.View style={spinStyle}>
<CircleNotch
size={22}
color="hsl(208, 100%, 57%)"
weight="bold"
/>
</Animated.View>
) : (
<ListBullets
size={22}
color="hsl(235, 10%, 55%)"
weight="bold"
/>
)}
{activeJobCount > 0 && (
<View
className="absolute -top-1 -right-1 bg-accent rounded-full min-w-[16px] h-[16px] items-center justify-center"
>
<Text className="text-white text-[10px] font-bold">
{activeJobCount > 9 ? "9+" : activeJobCount}
</Text>
</View>
)}
</View>
}
/>
<GlassButton
icon={
<Text className="text-ink text-2xl leading-none"></Text>
@@ -322,7 +388,7 @@ export function OverviewScreen() {
{/* Search Bar */}
<View className="px-4 mb-4" style={{ position: "relative", zIndex: 25 }} pointerEvents="auto">
<GlassSearchBar />
<GlassSearchBar onPress={handleSearchPress} editable={false} />
</View>
{/* Wrapper to elevate HeroStats above ScrollView for touch events */}

View File

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

View File

@@ -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<FileSearchInput, FileSearchOutput>({
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 };
}