refactor(mobile): remove unused navigation components and enhance search functionality

- Deleted obsolete navigation components including DrawerNavigator, RootNavigator, and TabNavigator to streamline the mobile app structure.
- Introduced the GlassSearchBar component for improved search capabilities across screens.
- Updated BrowseScreen to integrate the new search bar and enhance user experience with animated scrolling and dynamic content display.
- Adjusted layout and styling for better responsiveness and visual appeal.
- Added phosphor-react-native dependency for icon support in the GlassSearchBar.
This commit is contained in:
Jamie Pine
2026-01-22 11:22:18 -08:00
parent 4294015adc
commit 60803ab7ec
23 changed files with 403 additions and 1442 deletions

View File

@@ -1,309 +0,0 @@
# Explorer Tabs Implementation Summary
**Status:** Phase 1 Complete (MVP)
**Date:** December 24, 2025
**Branch:** `cursor/explorer-tab-interface-implementation-dec8`
---
## Overview
Successfully implemented browser-like tabs for the Spacedrive Explorer, allowing users to browse multiple locations simultaneously. The implementation follows the design document's Phase 1 (MVP) approach with simplified router management.
---
## What Was Implemented
### 1. Core Tab Infrastructure
**Created Files:**
- `packages/interface/src/components/TabManager/TabManagerContext.tsx`
- Core tab state management
- Tab creation, deletion, and switching logic
- Scroll state persistence per tab
- Single shared router for all tabs (simplified approach)
- `packages/interface/src/components/TabManager/TabBar.tsx`
- Visual tab bar component
- Tab titles and close buttons
- Active tab indicator with framer-motion animations
- New tab button (+)
- `packages/interface/src/components/TabManager/TabView.tsx`
- Tab content rendering component (prepared for future multi-router approach)
- `packages/interface/src/components/TabManager/useTabManager.ts`
- Type-safe hook for accessing tab manager context
- `packages/interface/src/components/TabManager/TabNavigationSync.tsx`
- Syncs router location with active tab's saved path
- Saves current location when navigating within a tab
- Restores saved location when switching to a different tab
- `packages/interface/src/components/TabManager/TabKeyboardHandler.tsx`
- Keyboard shortcut handlers for tab operations
- Uses existing keybind system infrastructure
- `packages/interface/src/components/TabManager/index.ts`
- Public API exports
### 2. Modified Files
**Router Configuration:**
- `packages/interface/src/router.tsx`
- Extracted route configuration as `explorerRoutes` array
- Kept `createExplorerRouter()` for backward compatibility
**Main Explorer:**
- `packages/interface/src/Explorer.tsx`
- Wrapped app in `TabManagerProvider`
- Added `TabKeyboardHandler` for global shortcuts
- Added `TabBar` component below TopBar
- Adjusted layout to flex-column for proper tab bar positioning
- Added `TabNavigationSync` inside router context
**Context Providers:**
- `packages/interface/src/components/Explorer/context.tsx`
- Added optional `isActiveTab` prop (for future multi-tab isolation)
- `packages/interface/src/components/Explorer/SelectionContext.tsx`
- Added optional `isActiveTab` prop
- Platform sync only active for active tab (prevents conflicts)
- Menu updates only for active tab
**Keybind Registry:**
- `packages/interface/src/util/keybinds/registry.ts`
- **Removed:** `explorer.openInNewTab` (conflicted with global.newTab)
- **Added:** Tab-related keybinds:
- `global.newTab` (Cmd+T) - Create new tab
- `global.closeTab` (Cmd+W) - Close active tab
- `global.nextTab` (Cmd+Shift+]) - Switch to next tab
- `global.previousTab` (Cmd+Shift+[) - Switch to previous tab
- `global.selectTab1-9` (Cmd+1-9) - Jump to specific tab
---
## Key Features
### ✅ Implemented
1. **Tab Creation**
- New tabs start at Overview (/)
- Keyboard shortcut: Cmd+T
- Click + button in tab bar
2. **Tab Closing**
- Close via × button on tab
- Keyboard shortcut: Cmd+W
- Last tab cannot be closed (prevents empty state)
3. **Tab Switching**
- Click tab to switch
- Keyboard: Cmd+Shift+[ / ] for prev/next
- Keyboard: Cmd+1-9 to jump to specific tab
4. **Navigation Persistence**
- Each tab remembers its last location
- Switching tabs restores saved location
- Independent navigation history per tab (via shared router)
5. **Visual Design**
- Tab bar positioned below TopBar
- Active tab indicator with smooth animation
- Semantic colors (bg-sidebar, text-sidebar-ink)
- Close button shows on hover
6. **Selection Isolation**
- Each tab maintains independent file selection
- Only active tab syncs to platform API
- Menu items update based on active tab's selection
---
## Architecture Decisions
### Simplified Router Approach
**Design Doc:** Each tab has its own router (browser router for active, memory router for inactive)
**Implementation:** Single shared browser router with path synchronization
**Rationale:**
- React Router v6's RouterProvider doesn't support dynamic router swapping
- Simpler state management for MVP
- Navigation still works independently per tab via saved paths
- Can be enhanced to multi-router in future if needed
### State Management
**Tab State:**
```typescript
interface Tab {
id: string; // Unique identifier
title: string; // Display name
savedPath: string; // Last location (e.g., "/explorer?path=...")
icon: string | null; // Future: location icon
isPinned: boolean; // Future: pinned tabs
lastActive: number; // Timestamp for LRU
}
```
**Scroll State:** Prepared but not yet implemented (Phase 4 feature)
### Context Isolation
Prepared for full isolation with `isActiveTab` prop on contexts:
- `ExplorerProvider({ isActiveTab })`
- `SelectionProvider({ isActiveTab })`
Currently all tabs use the same context instances (shared state), but platform sync is filtered by active tab to prevent conflicts.
---
## Testing Status
**Linting:** ✅ All files pass with no errors
**Manual Testing Needed:**
- [ ] Create multiple tabs
- [ ] Switch between tabs
- [ ] Navigate within tabs
- [ ] Close tabs
- [ ] Keyboard shortcuts (Cmd+T, Cmd+W, Cmd+Shift+[/])
- [ ] Tab switching remembers location
- [ ] File selection isolation
- [ ] Last tab cannot close
---
## Known Limitations (To Be Addressed in Future Phases)
1. **Scroll Position:** Not yet preserved when switching tabs
2. **View Mode:** Shared across tabs (not per-tab yet)
3. **Router Isolation:** Shared router (not per-tab router instances)
4. **Tab Titles:** Static "Overview" (should update based on location)
5. **Drag-Drop:** No drag-to-reorder tabs yet
6. **Persistence:** Tab state not saved on app restart
7. **Performance:** No lazy unmounting for inactive tabs
---
## Next Steps (Future Phases)
### Phase 2: Enhanced State Isolation
- Implement per-tab view mode and sort preferences
- Add dynamic tab titles based on location
- Per-tab scroll position preservation
### Phase 3: Performance Optimization
- Lazy mounting for inactive tabs
- Query client GC for inactive tabs
- Memory budget management
### Phase 4: Persistence
- Save/restore tab state on app restart
- Handle stale tabs (deleted locations)
### Phase 5: Polish
- Tab drag-to-reorder
- Tab context menu
- Cross-tab file drag-drop
- "Reopen Closed Tab" (Cmd+Shift+T)
- Tab close animations
---
## Code Quality
- ✅ No linter errors
- ✅ Follows CLAUDE.md guidelines (semantic colors, no React.FC, function components)
- ✅ Type-safe (full TypeScript)
- ✅ Documented with inline comments
- ✅ Follows existing patterns (TabBar similar to Inspector/Tabs.tsx)
- ✅ Uses existing infrastructure (useKeybind hook, framer-motion)
---
## Files Changed Summary
**New Files (7):**
- TabManager/TabManagerContext.tsx
- TabManager/TabBar.tsx
- TabManager/TabView.tsx
- TabManager/useTabManager.ts
- TabManager/TabNavigationSync.tsx
- TabManager/TabKeyboardHandler.tsx
- TabManager/index.ts
**Modified Files (5):**
- Explorer.tsx
- router.tsx
- components/Explorer/context.tsx
- components/Explorer/SelectionContext.tsx
- util/keybinds/registry.ts
**Total Lines Added:** ~600 lines
---
## Success Criteria (Phase 1)
✅ User can open multiple tabs (Cmd+T)
✅ User can close tabs (Cmd+W)
✅ User can switch tabs (Cmd+Shift+[/])
✅ Each tab maintains independent navigation
✅ Tab switching updates URL correctly
✅ No visual glitches during switching
✅ Last tab cannot be closed
✅ Keybinds work like browser tabs
✅ No memory leaks or crashes
✅ Code passes linting
---
## Risk Assessment
**Low Risk:**
- Well-isolated component (doesn't affect core Explorer logic)
- Uses existing infrastructure (keybinds, framer-motion)
- Can be disabled by removing TabManagerProvider wrapper
**Rollback Plan:**
If issues arise, simply remove:
1. TabManagerProvider wrapper from Explorer.tsx
2. TabBar import and usage
3. Restore original router.tsx structure
All other changes are backward-compatible.
---
## Performance Notes
**Current Implementation:**
- Single router (no per-tab overhead)
- All tabs loaded in memory (no lazy unmounting yet)
- Estimated memory per tab: ~5-10KB (just state, no rendered DOM)
**Future Optimization Targets:**
- Phase 3: Add lazy unmounting for 10+ tabs
- Phase 3: QueryClient GC for inactive tabs
---
## Documentation
See design document at `/workspace/.tasks/EXPLORER-TABS-DESIGN.md` for full architectural details and future roadmap.
---
## Conclusion
Phase 1 (MVP) successfully implements core tab functionality with a simplified architecture suitable for immediate use. The foundation is in place for future enhancements including full state isolation, performance optimization, and session persistence.
The implementation is production-ready for testing with the caveat that scroll position and view preferences are shared across tabs (to be addressed in Phase 2).

View File

@@ -50,6 +50,7 @@
"expo-splash-screen": "~31.0.11",
"expo-status-bar": "~3.0.8",
"nativewind": "^4.1.23",
"phosphor-react-native": "^2.1.0",
"react": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",

View File

@@ -1,35 +0,0 @@
import React, { useState } from "react";
import { View, ViewProps } from "react-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import { SpacedriveProvider } from "./client";
import { RootNavigator } from "./navigation";
import { AppResetContext } from "./contexts";
import "./global.css";
// Type workaround for GestureHandlerRootView children prop
const GestureRoot = GestureHandlerRootView as React.ComponentType<
ViewProps & { children?: React.ReactNode }
>;
export default function App() {
const [resetKey, setResetKey] = useState(0);
const resetApp = () => {
setResetKey((prev) => prev + 1);
};
return (
<GestureRoot style={{ flex: 1 }}>
<SafeAreaProvider>
<StatusBar style="light" />
<AppResetContext.Provider value={{ resetApp }}>
<SpacedriveProvider key={resetKey} deviceName="Spacedrive Mobile">
<RootNavigator />
</SpacedriveProvider>
</AppResetContext.Provider>
</SafeAreaProvider>
</GestureRoot>
);
}

View File

@@ -30,15 +30,6 @@ export default function TabLayout() {
)}
</NativeTabs.Trigger>
<NativeTabs.Trigger name="network">
<Label>Network</Label>
{Platform.OS === 'ios' ? (
<Icon sf="network" />
) : (
<Icon name="wifi" />
)}
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings">
<Label>Settings</Label>
{Platform.OS === 'ios' ? (

View File

@@ -1,11 +1,12 @@
import React, { forwardRef } from "react";
import { TextInput, View, Pressable, Platform, type TextInputProps } from "react-native";
import { TextInput, View, Pressable, type TextInputProps } from "react-native";
import { BlurView } from "expo-blur";
import {
LiquidGlassView,
isLiquidGlassSupported,
} from "@callstack/liquid-glass";
import { useRouter } from "expo-router";
import { MagnifyingGlass } from "phosphor-react-native";
interface GlassSearchBarProps extends Omit<TextInputProps, 'style'> {
onPress?: () => void;
@@ -26,17 +27,14 @@ export const GlassSearchBar = forwardRef<TextInput, GlassSearchBarProps>(
};
const content = (
<View className="flex-1 px-4 py-3 flex-row items-center gap-3">
<View className="w-5 h-5 items-center justify-center">
<View className="w-4 h-4 rounded-full border-2 border-ink-dull" />
<View className="absolute right-0 bottom-0 w-2 h-2 bg-ink-dull rotate-45 origin-bottom-right" style={{ transform: [{ scaleX: 0.5 }] }} />
</View>
<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 files, tags, locations..."
placeholder="Search library"
placeholderTextColor="hsl(235, 10%, 55%)"
className="flex-1 text-ink text-base"
className="flex-1 text-ink text-base text-md"
cursorColor="hsl(220, 90%, 56%)"
{...textInputProps}
/>

View File

@@ -1,32 +0,0 @@
import React from "react";
import {
createDrawerNavigator,
DrawerContentComponentProps,
} from "@react-navigation/drawer";
import { TabNavigator } from "./TabNavigator";
import { SidebarContent } from "../components/sidebar/SidebarContent";
import type { DrawerParamList } from "./types";
const Drawer = createDrawerNavigator<DrawerParamList>();
export function DrawerNavigator() {
return (
<Drawer.Navigator
drawerContent={(props: DrawerContentComponentProps) => (
<SidebarContent {...props} />
)}
screenOptions={{
headerShown: false,
drawerType: "slide",
drawerStyle: {
width: 280,
backgroundColor: "hsl(235, 15%, 16%)",
},
overlayColor: "rgba(0, 0, 0, 0.5)",
swipeEdgeWidth: 50,
}}
>
<Drawer.Screen name="Tabs" component={TabNavigator} />
</Drawer.Navigator>
);
}

View File

@@ -1,38 +0,0 @@
import React from "react";
import { NavigationContainer, DefaultTheme } from "@react-navigation/native";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { DrawerNavigator } from "./DrawerNavigator";
import type { RootStackParamList } from "./types";
const Stack = createNativeStackNavigator<RootStackParamList>();
// Dark theme for navigation
const SpacedriveTheme = {
...DefaultTheme,
dark: true,
colors: {
...DefaultTheme.colors,
primary: "hsl(208, 100%, 57%)",
background: "hsl(235, 15%, 13%)",
card: "hsl(235, 10%, 6%)",
text: "hsl(235, 0%, 100%)",
border: "hsl(235, 15%, 23%)",
notification: "hsl(208, 100%, 57%)",
},
};
export function RootNavigator() {
return (
<NavigationContainer theme={SpacedriveTheme}>
<Stack.Navigator
screenOptions={{
headerShown: false,
animation: "fade",
}}
>
<Stack.Screen name="Main" component={DrawerNavigator} />
{/* Add Onboarding and Search screens later */}
</Stack.Navigator>
</NavigationContainer>
);
}

View File

@@ -1,105 +0,0 @@
import React from "react";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { View, Text, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { OverviewStack } from "./stacks/OverviewStack";
import { BrowseStack } from "./stacks/BrowseStack";
import { NetworkStack } from "./stacks/NetworkStack";
import { SettingsStack } from "./stacks/SettingsStack";
import type { TabParamList } from "./types";
import { useJobs } from "../hooks/useJobs";
const Tab = createBottomTabNavigator<TabParamList>();
// Simple icon components (replace with phosphor-react-native later)
const TabIcon = ({ name, focused, badge }: { name: string; focused: boolean; badge?: number }) => (
<View
className={`items-center justify-center ${focused ? "opacity-100" : "opacity-50"}`}
>
<View className="relative">
<View
className={`h-6 w-6 rounded-md ${focused ? "bg-accent" : "bg-ink-faint"}`}
/>
{badge !== undefined && badge > 0 && (
<View className="absolute -right-2 -top-2 bg-accent rounded-full min-w-[16px] h-[16px] items-center justify-center px-1">
<Text className="text-white text-[9px] font-bold">
{badge > 99 ? '99+' : badge}
</Text>
</View>
)}
</View>
<Text
className={`text-[10px] mt-1 ${focused ? "text-accent" : "text-ink-faint"}`}
>
{name}
</Text>
</View>
);
function OverviewTabIcon({ focused }: { focused: boolean }) {
const { activeJobCount } = useJobs();
return <TabIcon name="Overview" focused={focused} badge={activeJobCount} />;
}
export function TabNavigator() {
const insets = useSafeAreaInsets();
const tabBarHeight = Platform.OS === "ios" ? 80 : 60;
return (
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarStyle: {
height:
tabBarHeight +
(Platform.OS === "ios" ? 0 : insets.bottom),
paddingBottom: Platform.OS === "ios" ? insets.bottom : 8,
paddingTop: 8,
backgroundColor: "hsl(235, 10%, 6%)",
borderTopColor: "hsl(235, 15%, 23%)",
borderTopWidth: 1,
},
tabBarShowLabel: false,
tabBarActiveTintColor: "hsl(208, 100%, 57%)",
tabBarInactiveTintColor: "hsl(235, 10%, 55%)",
}}
>
<Tab.Screen
name="OverviewTab"
component={OverviewStack}
options={{
tabBarIcon: ({ focused }) => (
<OverviewTabIcon focused={focused} />
),
}}
/>
<Tab.Screen
name="BrowseTab"
component={BrowseStack}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon name="Browse" focused={focused} />
),
}}
/>
<Tab.Screen
name="NetworkTab"
component={NetworkStack}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon name="Network" focused={focused} />
),
}}
/>
<Tab.Screen
name="SettingsTab"
component={SettingsStack}
options={{
tabBarIcon: ({ focused }) => (
<TabIcon name="Settings" focused={focused} />
),
}}
/>
</Tab.Navigator>
);
}

View File

@@ -1,2 +0,0 @@
export { RootNavigator } from "./RootNavigator";
export type * from "./types";

View File

@@ -1,16 +0,0 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { BrowseScreen } from "../../screens/browse/BrowseScreen";
import { ExplorerScreen } from "../../screens/explorer/ExplorerScreen";
import type { BrowseStackParamList } from "../types";
const Stack = createNativeStackNavigator<BrowseStackParamList>();
export function BrowseStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="BrowseHome" component={BrowseScreen} />
<Stack.Screen name="Explorer" component={ExplorerScreen} />
</Stack.Navigator>
);
}

View File

@@ -1,14 +0,0 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { NetworkScreen } from "../../screens/network/NetworkScreen";
import type { NetworkStackParamList } from "../types";
const Stack = createNativeStackNavigator<NetworkStackParamList>();
export function NetworkStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Network" component={NetworkScreen} />
</Stack.Navigator>
);
}

View File

@@ -1,14 +0,0 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { OverviewScreen } from "../../screens/overview/OverviewScreen";
import type { OverviewStackParamList } from "../types";
const Stack = createNativeStackNavigator<OverviewStackParamList>();
export function OverviewStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Overview" component={OverviewScreen} />
</Stack.Navigator>
);
}

View File

@@ -1,14 +0,0 @@
import React from "react";
import { createNativeStackNavigator } from "@react-navigation/native-stack";
import { SettingsScreen } from "../../screens/settings/SettingsScreen";
import type { SettingsStackParamList } from "../types";
const Stack = createNativeStackNavigator<SettingsStackParamList>();
export function SettingsStack() {
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="SettingsHome" component={SettingsScreen} />
</Stack.Navigator>
);
}

View File

@@ -1,58 +0,0 @@
import { NavigatorScreenParams } from "@react-navigation/native";
// Root stack contains main app and onboarding
export type RootStackParamList = {
Main: NavigatorScreenParams<DrawerParamList>;
Onboarding: undefined;
Search: undefined;
};
// Drawer contains the sidebar and tab navigator
export type DrawerParamList = {
Tabs: NavigatorScreenParams<TabParamList>;
};
// Bottom tabs
export type TabParamList = {
OverviewTab: NavigatorScreenParams<OverviewStackParamList>;
BrowseTab: NavigatorScreenParams<BrowseStackParamList>;
NetworkTab: NavigatorScreenParams<NetworkStackParamList>;
SettingsTab: NavigatorScreenParams<SettingsStackParamList>;
};
// Overview stack
export type OverviewStackParamList = {
Overview: undefined;
};
// Browse stack
export type BrowseStackParamList = {
BrowseHome: undefined;
Explorer:
| { type: "path"; path: string } // JSON.stringify(SdPath)
| { type: "view"; view: string; id?: string }; // Virtual views
};
// Network stack
export type NetworkStackParamList = {
Network: undefined;
Peers: undefined;
Pairing: undefined;
};
// Settings stack
export type SettingsStackParamList = {
SettingsHome: undefined;
GeneralSettings: undefined;
LibrarySettings: undefined;
AppearanceSettings: undefined;
PrivacySettings: undefined;
About: undefined;
};
// Utility types for typed navigation
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}

View File

@@ -1,17 +1,27 @@
import React, { useState, useRef, useCallback } from "react";
import { useState, useRef, useCallback } from "react";
import {
View,
Text,
ScrollView,
Dimensions,
NativeScrollEvent,
NativeSyntheticEvent,
type NativeScrollEvent,
type NativeSyntheticEvent,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useSafeAreaInsets, type EdgeInsets } from "react-native-safe-area-context";
import Animated, {
useSharedValue,
useAnimatedScrollHandler,
useAnimatedStyle,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { useNormalizedQuery } from "../../client";
import { DevicesGroup, LocationsGroup, VolumesGroup } from "./components";
import { PageIndicator } from "../../components/PageIndicator";
import { GlassSearchBar } from "../../components/GlassSearchBar";
import sharedColors from "@sd/ui/style/colors";
import type { SpaceItem, SpaceGroup } from "@sd/ts-client";
import { SpaceItem as SpaceItemComponent, SpaceGroupComponent } from "./components";
import { SettingsGroup } from "../../components/primitive";
interface Space {
id: string;
@@ -21,40 +31,100 @@ interface Space {
const SCREEN_WIDTH = Dimensions.get("window").width;
function SpaceContent({ space, insets }: { space: Space; insets: any }) {
function SpaceContent({
space,
insets
}: {
space: Space;
insets: EdgeInsets;
}) {
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
// Fetch space layout
const { data: layout } = useNormalizedQuery({
query: "spaces.get_layout",
input: { space_id: space.id },
resourceType: "space_layout",
resourceId: space.id,
enabled: !!space.id,
});
// Space name scale on overscroll (anchored left)
const spaceNameScale = useAnimatedStyle(() => {
const scale = interpolate(
scrollY.value,
[-200, 0],
[1.3, 1],
Extrapolation.CLAMP
);
return {
transform: [{ scale }],
transformOrigin: 'left center',
};
});
// Filter out Overview items (mobile doesn't show Overview in browse tab)
const spaceItems = (layout?.space_items || []).filter(
(item) => item.item_type !== "Overview"
);
const groups = layout?.groups || [];
return (
<ScrollView
<Animated.ScrollView
style={{ width: SCREEN_WIDTH }}
contentContainerStyle={{
paddingTop: insets.top + 80,
paddingTop: insets.top + 45,
paddingHorizontal: 16,
paddingBottom: insets.bottom + 100,
paddingBottom: insets.bottom + 60,
}}
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
>
{/* Header */}
<View className="mb-6">
<View className="flex-row items-center gap-2 mb-1">
<View className="flex-row items-center gap-2">
<View
className="w-3 h-3 rounded-full"
className="w-4 h-4 mx-1 rounded-full"
style={{ backgroundColor: space.color }}
/>
<Text className="text-2xl font-bold text-ink">{space.name}</Text>
<Animated.Text
style={[spaceNameScale]}
className="text-ink text-[30px] font-bold"
>
{space.name}
</Animated.Text>
</View>
<Text className="text-ink-dull text-sm">
Your libraries and spaces
</Text>
</View>
{/* Locations */}
<LocationsGroup />
{/* Search Bar */}
<View className="mb-6">
<GlassSearchBar />
</View>
{/* Devices */}
<DevicesGroup />
{/* Space Items (pinned shortcuts) */}
{spaceItems.length > 0 && (
<View className="mb-6">
<SettingsGroup>
{spaceItems.map((item) => (
<SpaceItemComponent key={item.id} item={item} />
))}
</SettingsGroup>
</View>
)}
{/* Volumes */}
<VolumesGroup />
</ScrollView>
{/* Groups */}
{groups.map(({ group, items }) => (
<SpaceGroupComponent key={group.id} group={group} items={items} />
))}
</Animated.ScrollView>
);
}
@@ -117,7 +187,11 @@ export function BrowseScreen() {
decelerationRate="fast"
>
{spacesList.map((space) => (
<SpaceContent key={space.id} space={space} insets={insets} />
<SpaceContent
key={space.id}
space={space}
insets={insets}
/>
))}
<CreateSpaceScreen />
</ScrollView>

View File

@@ -0,0 +1,71 @@
import React, { useState } from "react";
import { View, Text, Pressable } from "react-native";
import type { SpaceGroup, SpaceItem } from "@sd/ts-client";
import { SettingsGroup } from "../../../components/primitive";
import { SpaceItem as SpaceItemComponent } from "./SpaceItem";
import { DevicesGroup } from "./DevicesGroup";
import { LocationsGroup } from "./LocationsGroup";
import { VolumesGroup } from "./VolumesGroup";
import { CaretDown, CaretRight } from "phosphor-react-native";
interface SpaceGroupProps {
group: SpaceGroup;
items: SpaceItem[];
}
export function SpaceGroupComponent({ group, items }: SpaceGroupProps) {
const [isCollapsed, setIsCollapsed] = useState(group.is_collapsed ?? false);
const handleToggle = () => {
setIsCollapsed(!isCollapsed);
};
// System groups - use existing components
if (group.group_type === "Devices") {
return <DevicesGroup />;
}
if (group.group_type === "Locations") {
return <LocationsGroup />;
}
if (group.group_type === "Volumes") {
return <VolumesGroup />;
}
if (group.group_type === "Tags") {
// Tags group not implemented yet
return null;
}
// Custom/QuickAccess groups - render items
return (
<View className="mb-6">
{/* Group Header */}
<Pressable
onPress={handleToggle}
className="flex-row items-center justify-between px-4 mb-2"
>
<Text className="text-xs font-semibold text-ink-dull uppercase tracking-wider">
{group.name}
</Text>
{isCollapsed ? (
<CaretRight size={16} color="hsl(235, 10%, 55%)" weight="bold" />
) : (
<CaretDown size={16} color="hsl(235, 10%, 55%)" weight="bold" />
)}
</Pressable>
{/* Group Items */}
{!isCollapsed && items.length > 0 && (
<SettingsGroup>
{items
.filter((item) => item.item_type !== "Overview")
.map((item) => (
<SpaceItemComponent key={item.id} item={item} />
))}
</SettingsGroup>
)}
</View>
);
}

View File

@@ -0,0 +1,224 @@
import React from "react";
import { Image } from "react-native";
import { useRouter } from "expo-router";
import type { SpaceItem as SpaceItemType, ItemType, SdPath } from "@sd/ts-client";
import { SettingsLink } from "../../../components/primitive";
import FolderIcon from "@sd/assets/icons/Folder.png";
import { MagnifyingGlass, Clock, Heart, Folders, HardDrive, Tag } from "phosphor-react-native";
// Type guards
function isOverviewItem(t: ItemType): t is "Overview" {
return t === "Overview";
}
function isRecentsItem(t: ItemType): t is "Recents" {
return t === "Recents";
}
function isFavoritesItem(t: ItemType): t is "Favorites" {
return t === "Favorites";
}
function isFileKindsItem(t: ItemType): t is "FileKinds" {
return t === "FileKinds";
}
function isLocationItem(t: ItemType): t is { Location: { location_id: string } } {
return typeof t === "object" && "Location" in t;
}
function isVolumeItem(t: ItemType): t is { Volume: { volume_id: string } } {
return typeof t === "object" && "Volume" in t;
}
function isTagItem(t: ItemType): t is { Tag: { tag_id: string } } {
return typeof t === "object" && "Tag" in t;
}
function isPathItem(t: ItemType): t is { Path: { sd_path: SdPath } } {
return typeof t === "object" && "Path" in t;
}
function isRawLocation(item: SpaceItemType | Record<string, unknown>): boolean {
return "name" in item && "sd_path" in item && !("item_type" in item);
}
// Get icon for item type
function getItemIcon(itemType: ItemType): React.ReactNode {
if (isOverviewItem(itemType)) {
return <MagnifyingGlass size={20} color="hsl(235, 10%, 55%)" weight="bold" />;
}
if (isRecentsItem(itemType)) {
return <Clock size={20} color="hsl(235, 10%, 55%)" weight="bold" />;
}
if (isFavoritesItem(itemType)) {
return <Heart size={20} color="hsl(235, 10%, 55%)" weight="bold" />;
}
if (isFileKindsItem(itemType)) {
return <Folders size={20} color="hsl(235, 10%, 55%)" weight="bold" />;
}
if (isLocationItem(itemType) || isPathItem(itemType)) {
return (
<Image
source={FolderIcon}
className="w-5 h-5"
style={{ resizeMode: "contain" }}
/>
);
}
if (isVolumeItem(itemType)) {
return <HardDrive size={20} color="hsl(235, 10%, 55%)" weight="bold" />;
}
if (isTagItem(itemType)) {
return <Tag size={20} color="hsl(235, 10%, 55%)" weight="bold" />;
}
return (
<Image
source={FolderIcon}
className="w-5 h-5"
style={{ resizeMode: "contain" }}
/>
);
}
// Get label for item type
function getItemLabel(itemType: ItemType, resolvedFile?: any): string {
if (isOverviewItem(itemType)) return "Overview";
if (isRecentsItem(itemType)) return "Recents";
if (isFavoritesItem(itemType)) return "Favorites";
if (isFileKindsItem(itemType)) return "File Kinds";
if (isLocationItem(itemType)) return itemType.Location.name || "Unnamed Location";
if (isVolumeItem(itemType)) return itemType.Volume.name || "Unnamed Volume";
if (isTagItem(itemType)) return itemType.Tag.name || "Unnamed Tag";
if (isPathItem(itemType)) {
if (resolvedFile?.name) return resolvedFile.name;
const sdPath = itemType.Path.sd_path;
if (typeof sdPath === "object" && "Physical" in sdPath) {
const parts = (sdPath as { Physical: { path: string } }).Physical.path.split("/");
return parts[parts.length - 1] || "Path";
}
return "Path";
}
return "Unknown";
}
// Get navigation params for item (mobile uses different format)
function getItemNavigation(itemType: ItemType, itemSdPath?: SdPath): { pathname: string; params?: any } | null {
if (isOverviewItem(itemType)) {
return { pathname: "/" };
}
if (isRecentsItem(itemType)) {
return { pathname: "/recents" };
}
if (isFavoritesItem(itemType)) {
return { pathname: "/favorites" };
}
if (isFileKindsItem(itemType)) {
return { pathname: "/file-kinds" };
}
if (isLocationItem(itemType)) {
if (itemSdPath) {
return {
pathname: "/explorer",
params: {
type: "path",
path: JSON.stringify(itemSdPath),
},
};
}
return null;
}
if (isVolumeItem(itemType)) {
// Volume navigation handled separately
return null;
}
if (isTagItem(itemType)) {
return {
pathname: "/explorer",
params: {
type: "view",
view: "tag",
id: itemType.Tag.tag_id,
},
};
}
if (isPathItem(itemType)) {
return {
pathname: "/explorer",
params: {
type: "path",
path: JSON.stringify(itemType.Path.sd_path),
},
};
}
return null;
}
interface SpaceItemProps {
item: SpaceItemType;
}
export function SpaceItem({ item }: SpaceItemProps) {
const router = useRouter();
// Handle raw location (legacy format)
if (isRawLocation(item)) {
const rawItem = item as { name?: string; sd_path?: SdPath };
const label = rawItem.name || "Unnamed Location";
return (
<SettingsLink
icon={
<Image
source={FolderIcon}
className="w-5 h-5"
style={{ resizeMode: "contain" }}
/>
}
label={label}
onPress={() => {
if (rawItem.sd_path) {
router.push({
pathname: "/explorer",
params: {
type: "path",
path: JSON.stringify(rawItem.sd_path),
},
});
}
}}
/>
);
}
// Normal space item
const itemType = item.item_type;
const icon = getItemIcon(itemType);
const label = getItemLabel(itemType, item.resolved_file);
const navigation = getItemNavigation(itemType, item.sd_path);
// Handle volume items specially
if (isVolumeItem(itemType)) {
// Volumes are handled by VolumesGroup component
return null;
}
if (!navigation) {
return null;
}
return (
<SettingsLink
icon={icon}
label={label}
onPress={() => {
router.push(navigation);
}}
/>
);
}

View File

@@ -1,3 +1,5 @@
export { DevicesGroup } from "./DevicesGroup";
export { LocationsGroup } from "./LocationsGroup";
export { VolumesGroup } from "./VolumesGroup";
export { SpaceItem } from "./SpaceItem";
export { SpaceGroupComponent } from "./SpaceGroup";

View File

@@ -306,7 +306,7 @@ export function OverviewScreen() {
heroAnimatedStyle,
]}
>
<View className="px-8 pb-4 flex-row items-center gap-3">
<View className="px-4 pb-4 flex-row items-center gap-3">
<Animated.Text
style={[libraryNameScale]}
className="text-ink text-[30px] font-bold flex-1"
@@ -321,7 +321,7 @@ export function OverviewScreen() {
</View>
{/* Search Bar */}
<View className="px-8 mb-4" style={{ position: "relative", zIndex: 25 }} pointerEvents="auto">
<View className="px-4 mb-4" style={{ position: "relative", zIndex: 25 }} pointerEvents="auto">
<GlassSearchBar />
</View>

View File

@@ -1,227 +0,0 @@
# Spacedrive Daemon Setup Guide
## Overview
The Spacedrive daemon is a background process that handles file indexing, networking, and core operations. On macOS, running background processes requires special permissions and configuration.
## Production Build Configuration
### 1. macOS Entitlements
The app requires entitlements to spawn daemon processes. These are configured in `src-tauri/Entitlements.plist`.
**To enable in `tauri.conf.json`:**
```json
{
"bundle": {
"macOS": {
"minimumSystemVersion": "10.15",
"entitlements": "Entitlements.plist"
}
}
}
```
### 2. Background Item Permission
macOS 13+ (Ventura) requires explicit user permission for background items.
**User Action Required:**
1. System Settings → General → Login Items & Extensions
2. Find "Spacedrive" in the list
3. Enable the background item permission
The DaemonManager screen includes a button to open these settings directly.
### 3. Daemon Binary Location
The daemon binary (`sd-daemon`) must be bundled with the app:
**Development:**
- Located at: `workspace/target/debug/sd-daemon`
- Built with: `cargo build --bin sd-daemon`
**Production:**
- Must be in app bundle's Resources directory
- Configure in `tauri.conf.json`:
```json
{
"bundle": {
"resources": [
"../../../target/release/sd-daemon"
],
"externalBin": [
"sd-daemon"
]
}
}
```
## Daemon Operation Modes
### Mode 1: Background Process (Preferred)
Daemon runs as a separate system process:
**Advantages:**
- Survives app restarts
- Lower memory footprint
- Can run headless
**Requirements:**
- macOS background item permission
- `sd-daemon` binary bundled with app
### Mode 2: In-Process (Fallback)
Daemon runs within the Tauri app process:
**Advantages:**
- No permission required
- Always works
**Disadvantages:**
- Daemon dies when app closes
- Higher memory usage
**Implementation Note:** In-process mode is not yet implemented. When permission is denied, the app should fall back to this mode automatically.
## DaemonManager UI
Access via `/daemon` route in the app.
**Features:**
- Real-time daemon status
- Start/Stop controls
- Socket and server URL display
- Settings toggles (auto-start, run in-process)
- Quick link to macOS system settings
## Development
### Testing Daemon Spawn
```bash
# Build daemon
cargo build --bin sd-daemon
# Build and run Tauri app
cd apps/tauri
bun run tauri dev
```
### Check Daemon Status
```bash
# Check if socket exists
ls -la ~/.local/share/spacedrive/daemon/daemon.sock
# Try connecting
echo '{"Query":{"type":"ping"}}' | nc -U ~/.local/share/spacedrive/daemon/daemon.sock
```
### Common Issues
**"Daemon binary not found"**
- Run `cargo build --bin sd-daemon` first
- Check `target/debug/sd-daemon` exists
**"Permission denied" on spawn**
- macOS blocked the background process
- Check System Settings → Login Items
- Enable Spacedrive background permission
**"Socket not created after 3 seconds"**
- Daemon crashed on startup
- Check daemon logs: `~/.local/share/spacedrive/daemon.log`
- Verify data directory permissions
**"Failed to connect to daemon"**
- Socket file exists but daemon not running (stale socket)
- App will auto-clean stale sockets
- Try manual cleanup: `rm ~/.local/share/spacedrive/daemon/daemon.sock`
## Architecture
### Tauri Commands
- `get_daemon_status()` - Get current daemon state
- `start_daemon_process()` - Spawn daemon as background process
- `stop_daemon_process()` - Kill daemon (only if we started it)
- `open_macos_settings()` - Open system settings for permissions
- `get_daemon_socket()` - Get socket path for client connection
- `get_server_url()` - Get HTTP server URL for sidecars
### State Management
```rust
struct DaemonState {
started_by_us: bool, // Did we spawn it?
socket_path: PathBuf, // Unix socket location
data_dir: PathBuf, // Spacedrive data directory
server_url: Option<String>, // HTTP server for files
daemon_process: Option<Child>, // Process handle (if we spawned it)
}
```
### Startup Flow
1. App launches
2. Check if daemon already running (try connect to socket)
3. If running: connect and use it
4. If not running: spawn new daemon process
5. Wait for socket to be created (max 3 seconds)
6. Connect and initialize
### Shutdown Flow
1. App closing
2. Check `started_by_us` flag
3. If true: kill daemon process
4. If false: leave it running (another app instance may be using it)
## Future Enhancements
### TODO: In-Process Mode
Implement fallback when background permission denied:
```rust
#[tauri::command]
async fn start_daemon_in_process(
data_dir: PathBuf,
state: State<DaemonState>,
) -> Result<(), String> {
// Spawn daemon as tokio task instead of separate process
tauri::async_runtime::spawn(async move {
sd_daemon::run(data_dir).await
});
Ok(())
}
```
### TODO: Settings Persistence
Save user preferences:
- Auto-start daemon on launch
- Prefer in-process mode
- Daemon log level
Use Tauri's store plugin or save to daemon config file.
### TODO: ServiceManagement Framework
For better macOS integration, use Apple's ServiceManagement framework to register the daemon as a login item programmatically.
```rust
// Use cocoa/objc to call SMAppService APIs
// This would show the system permission dialog automatically
```
## Resources
- [macOS Background Task API](https://developer.apple.com/documentation/servicemanagement)
- [Tauri Bundling Guide](https://tauri.app/v1/guides/building/macos)
- [Unix Domain Sockets](https://man7.org/linux/man-pages/man7/unix.7.html)

View File

@@ -1,237 +0,0 @@
# Native Drag & Drop System
A production-ready native drag-and-drop implementation for Spacedrive using AppKit on macOS.
## Features
- **Native OS Integration**: Real `NSDraggingSession` - files can be dropped into Finder, other apps
- **Custom Overlay**: User-controlled React component follows cursor during drag
- **Multi-Window Support**: Drag state synchronized across all Spacedrive windows via Tauri events
- **Live Updates**: Overlay can react to drag events in real-time
- **Type-Safe**: Full TypeScript definitions matching Rust types
- **File Promises**: Support for virtual files generated on-drop
## Quick Start
### 1. Start a Drag Operation
```typescript
import { useDragOperation } from './hooks/useDragOperation';
function MyComponent() {
const { startDrag, isDragging } = useDragOperation({
onDragStart: (sessionId) => console.log('Started:', sessionId),
onDragEnd: (result) => console.log('Result:', result),
});
const handleDrag = async () => {
await startDrag({
items: [
{
id: 'file-1',
kind: { type: 'File', path: '/path/to/file.pdf' }
}
],
allowedOperations: ['copy', 'move'],
});
};
return <button onClick={handleDrag}>Drag File</button>;
}
```
### 2. Create a Drop Zone
```typescript
import { useDropZone } from './hooks/useDropZone';
function DropTarget() {
const { isHovered, dropZoneProps } = useDropZone({
onDrop: (items) => console.log('Dropped:', items),
});
return (
<div
{...dropZoneProps}
className={isHovered ? 'border-blue-500' : 'border-gray-300'}
>
Drop files here
</div>
);
}
```
### 3. Customize the Drag Overlay
The overlay component at `/drag-overlay` renders during drag operations:
```typescript
// apps/tauri/src/routes/DragOverlay.tsx
export function DragOverlay() {
const [session, setSession] = useState<DragSession | null>(null);
useEffect(() => {
getDragSession().then(setSession);
}, []);
return (
<div className="custom-drag-preview">
{session?.config.items.length} files
</div>
);
}
```
## Architecture
```
React App Rust/Tauri macOS (Swift)
┌─────────────┐ ┌──────────────┐ ┌───────────────┐
│ │ │ │ │ │
│ startDrag() ├─invoke────► │ begin_drag ├─FFI─────► │ beginNative │
│ │ │ │ │ Drag │
│ │ │ │ │ │
│ │ │ DragCoord │ │ NSDragging │
│ │ │ inator │ │ Source │
│ │ ◄──────────┤ ◄───NSNotif──┤ │
│ onDragMoved │ emit event │ emit to all │ │ draggingMoved │
│ │ │ windows │ │ │
└─────────────┘ └──────────────┘ └───────────────┘
```
## API Reference
### `useDragOperation(options)`
Hook for initiating drag operations.
**Options:**
- `onDragStart?: (sessionId: string) => void`
- `onDragMove?: (x: number, y: number) => void`
- `onDragEnd?: (result: DragResult) => void`
**Returns:**
- `isDragging: boolean`
- `currentSession: DragSession | null`
- `cursorPosition: { x: number; y: number } | null`
- `startDrag: (config) => Promise<string>`
- `cancelDrag: (sessionId) => Promise<void>`
### `useDropZone(options)`
Hook for creating drop targets.
**Options:**
- `onDrop?: (items: DragItem[]) => void`
- `onDragEnter?: () => void`
- `onDragLeave?: () => void`
**Returns:**
- `isHovered: boolean`
- `dragItems: DragItem[]`
- `dropZoneProps: object` - spread onto your drop zone element
### Types
```typescript
type DragItemKind =
| { type: 'File'; path: string }
| { type: 'FilePromise'; name: string; mimeType: string }
| { type: 'Text'; content: string };
interface DragItem {
kind: DragItemKind;
id: string;
}
interface DragConfig {
items: DragItem[];
overlayUrl: string;
overlaySize: [number, number];
allowedOperations: DragOperation[];
}
```
## Demo
Run the demo window:
```bash
# From apps/tauri
bun run dev
```
Then open the drag demo by changing the main window label to `drag-demo` or by programmatically showing it:
```typescript
import { invoke } from '@tauri-apps/api/core';
await invoke('show_window', {
window: { type: 'Main' } // Opens the demo
});
```
## Implementation Details
### Rust Layer (`src-tauri/src/drag/`)
- **`mod.rs`**: `DragCoordinator` - global state manager
- **`session.rs`**: Session tracking with UUIDs
- **`events.rs`**: Type-safe event definitions
- **`commands.rs`**: Tauri command handlers
### Swift Layer (`crates/macos/src-swift/drag.swift`)
- **`NativeDragSource`**: Implements `NSDraggingSource` protocol
- **File Promises**: Implements `NSFilePromiseProviderDelegate`
- **Notification Bridge**: Sends events back to Rust via `NotificationCenter`
### TypeScript Layer (`src/`)
- **`lib/drag.ts`**: Low-level API wrappers
- **`hooks/useDragOperation.ts`**: Drag initiation hook
- **`hooks/useDropZone.ts`**: Drop target hook
- **`routes/DragOverlay.tsx`**: Cursor-following overlay component
## Known Limitations
- **macOS only** - Windows/Linux support not yet implemented
- **Overlay mouse events**: Currently the overlay ignores all mouse events (by Tauri config, not objc2 calls)
- **File promise callbacks**: Requires implementing file generation logic via NSNotification
## Future Enhancements
1. **Windows Support**: Implement OLE drag-drop
2. **Linux Support**: X11/Wayland integration
3. **Bidirectional Drop**: Handle drops FROM external apps INTO Spacedrive
4. **Drag Modifiers**: Support Cmd/Ctrl for copy vs. move
5. **Multi-Monitor**: Better positioning for multi-display setups
## Troubleshooting
**Drag doesn't start:**
- Check console for Rust errors
- Ensure `begin_drag` command is registered in `main.rs`
- Verify Swift build succeeded
**Overlay doesn't appear:**
- Check `DragOverlay` window is created
- Verify `/drag-overlay` route exists
- Check for TypeScript errors in overlay component
**Drop doesn't work:**
- Ensure `useDropZone` hook is mounted before drag starts
- Check window labels match between drag source and drop target
- Verify event listeners are properly cleaned up
## Contributing
This is an experimental feature. Contributions welcome, especially for:
- Windows/Linux platform support
- Better error handling
- Performance optimizations
- Additional drag item types
## License
Same as Spacedrive project.

View File

@@ -1,299 +0,0 @@
# V1 Tauri Configuration Reference
This document captures the sophisticated Tauri configuration from V1 that should be incrementally ported to V2.
## Current V2 Status
**Already Ported:**
- Basic window configuration (1400x750, hidden title, transparent)
- `app_ready` command for controlled window showing
- Core plugins: dialog, fs, shell, clipboard-manager, os
- macOS private API enabled
- Basic CSP configuration
- Bundle settings for Linux/macOS/Windows
**TODO - High Priority:**
- [ ] Custom Tauri commands (file operations, menu management, reveal items)
- [ ] Platform-specific code (macOS Swift bridge, Linux/Windows file ops)
- [ ] Menu system (macOS menu bar with keybinds)
- [ ] Drag and drop tracking system
- [ ] Window effects (blur, vibrancy)
- [ ] Deep-link plugin for `spacedrive://` URL scheme
**TODO - Medium Priority:**
- [ ] Updater plugin and custom update flow
- [ ] Custom server plugin (for serving thumbnails)
- [ ] Error plugin (for pre-rspc error display)
- [ ] TypeScript bindings with tauri-specta
- [ ] Full screen detection and titlebar style switching
**TODO - Lower Priority:**
- [ ] CORS fetch plugin (for Supertokens auth)
- [ ] Linux environment normalization (XDG, GStreamer, Flatpak/Snap detection)
- [ ] Windows file association APIs
- [ ] AI models bundling (onnxruntime)
---
## V1 Custom Tauri Commands
These should be ported as needed:
### App Lifecycle
```rust
app_ready // Already ported
reset_spacedrive // TODO: Wipes data directory
reload_webview // TODO: Platform-specific webview reload
```
### Menu & UI
```rust
set_menu_bar_item_state // TODO: Enable/disable menu items
refresh_menu_bar // TODO: Update menu based on library
lock_app_theme // TODO: Force light/dark mode (macOS)
```
### File Operations
```rust
open_file_paths // TODO: Open files by library ID
open_ephemeral_files // TODO: Open files by path
get_file_path_open_with_apps // TODO: Get "Open With" apps
get_ephemeral_files_open_with_apps // TODO: Same for paths
open_file_path_with // TODO: Open with specific app
open_ephemeral_file_with // TODO: Open path with specific app
reveal_items // TODO: Reveal in file manager
open_logs_dir // TODO: Open logs directory
open_trash_in_os_explorer // TODO: Open OS trash
```
### Drag & Drop
```rust
start_drag // TODO: Cursor-tracked drag (macOS/Windows)
stop_drag // TODO: Stop drag tracking
```
### macOS Specific
```rust
request_fda_macos // TODO: Request Full Disk Access
set_titlebar_style // TODO: Custom titlebar
disable_app_nap // TODO: Prevent sleep during indexing
enable_app_nap // TODO: Re-enable sleep
```
### Updater
```rust
check_for_update // TODO: Check for updates
install_update // TODO: Download and install
```
---
## V1 Window Configuration
```json
{
"width": 1400,
"height": 750,
"minWidth": 768,
"minHeight": 500,
"hiddenTitle": true,
"transparent": true,
"center": true,
"visible": false, // Shown via app_ready
"dragDropEnabled": true,
"windowEffects": {
"effects": ["sidebar"],
"state": "followsWindowActiveState",
"radius": 9
}
}
```
**Current V2:** Basic config without windowEffects (add when needed).
---
## V1 Content Security Policy
```json
{
"default-src": "'self' webkit-pdfjs-viewer: asset: http://asset.localhost blob: data: filesystem: http: https: tauri:",
"connect-src": "'self' ipc: http://ipc.localhost ws: wss: http: https: tauri:",
"img-src": "'self' asset: http://asset.localhost blob: data: filesystem: http: https: tauri:",
"style-src": "'self' 'unsafe-inline' http: https: tauri:"
}
```
**Current V2:** Simplified CSP (expand as we add features like custom server).
---
## V1 Bundle Configuration
### Linux
```json
{
"deb": {
"depends": ["libc6", "libxdo3", "dbus", "libwebkit2gtk-4.1-0", "libgtk-3-0"],
"files": {
"/usr/share/spacedrive/models/yolov8s.onnx": "../../../models/yolov8s.onnx"
}
}
}
```
**Critical:** dbus must NOT be vendored (breaks on X11/Nvidia).
### macOS
```json
{
"minimumSystemVersion": "10.15",
"frameworks": [".deps/Spacedrive.framework"]
}
```
**V1 Pattern:** Custom Swift framework bundled (may need for V2 Swift bridge).
### Windows
```json
{
"webviewInstallMode": {
"type": "embedBootstrapper",
"silent": true
}
}
```
---
## V1 Plugins
### Official Tauri Plugins
```toml
tauri-plugin-clipboard-manager = "2.0" # Ported
tauri-plugin-deep-link = "2.0" # TODO
tauri-plugin-dialog = "2.0" # Ported
tauri-plugin-drag = "2.0" # TODO (custom fork with move operation)
tauri-plugin-http = "2.0" # TODO (if needed)
tauri-plugin-os = "2.0" # Ported
tauri-plugin-shell = "2.0" # Ported
tauri-plugin-updater = "2.0" # TODO
```
### Custom Plugins
- **tauri-plugin-cors-fetch:** Modified for Supertokens auth (TODO)
- **sd_server_plugin:** Axum server for custom URI protocol (TODO)
- **sd_error_plugin:** Injects `window.__SD_ERROR__` (TODO)
- **Updater injection:** Injects `window.__SD_UPDATER__` flag (TODO)
---
## V1 Platform-Specific Code
### macOS (Swift via swift-rs)
**Window Management:**
- `set_titlebar_style` - Invisible toolbar trick
- `disable_app_nap` / `enable_app_nap` - System sleep control
- `lock_app_theme` - Force light/dark mode
- `reload_webview` - Proper reload without artifacts
**File Operations:**
- `get_open_with_applications` - Apps that can open file (with icons)
- `open_file_path_with` - Open with specific application
- Fallback compatibility for macOS 10.15+
### Linux
**Environment Normalization (critical!):**
- XDG directory setup (HOME, DATA_HOME, CONFIG_HOME, etc.)
- GStreamer plugin paths
- PATH normalization
- Flatpak/Snap detection
- NVIDIA GPU workaround: `WEBKIT_DISABLE_DMABUF_RENDERER=1`
**File Operations:**
- GTK-based "Open With" detection
- Content type detection
- AppLaunchContext for opening files
- Fallback `getpwuid_r` for $HOME
### Windows
**File Operations:**
- `list_apps_associated_with_ext` - Uses `SHAssocEnumHandlers`
- `open_file_path_with` - Uses `IAssocHandler` and `IShellItem`
- COM initialization (thread-local with atexit)
---
## V1 Menu System (macOS)
Full macOS menu bar:
```
Spacedrive
├─ About Spacedrive
├─ New Library [Cmd+N]
├─ Hide [Cmd+H]
└─ Quit [Cmd+Q]
Edit
├─ Undo [Cmd+Z]
├─ Redo [Cmd+Shift+Z]
└─ Select All [Cmd+A]
View
├─ Overview [Cmd+Shift+O]
├─ Search [Cmd+F]
├─ Settings [Cmd+,]
├─ Layouts
└─ Dev Tools
Window
├─ Minimize [Cmd+M]
├─ Fullscreen [Cmd+Ctrl+F]
└─ Reload Webview
```
**Features:**
- Library-locked menu items (disabled until library loaded)
- Keybind forwarding to frontend
- Fullscreen detection with titlebar switching
---
## V1 Drag & Drop Implementation
**Advanced Features:**
- Cursor position tracking at 8ms intervals
- Detects when cursor leaves window bounds
- Creates drag session with base64 image preview
- Callback for drop result and position
- Linux: Disabled (not implemented)
**Custom `drag` crate:** Modified fork with "move-operation" branch from spacedriveapp.
---
## V1 Key Gotchas
1. **Linux dbus:** MUST NOT be vendored (breaks on X11/Nvidia)
2. **GTK version:** Must match Tauri's (0.18 with v3_24 feature)
3. **NVIDIA workaround:** `WEBKIT_DISABLE_DMABUF_RENDERER=1`
4. **Swift NULL delimiter hack:** For file path arrays
5. **Updater:** Disabled on Linux
6. **Tauri version:** Pinned to v2.0.6 exactly
7. **AI models:** Special linker config for onnxruntime TLS
---
## Next Steps for V2
1. **Immediate:** Keep current basic setup, focus on core integration
2. **Phase 2:** Add file operation commands as UI needs them
3. **Phase 3:** Port platform-specific code (Swift bridge, Linux normalization)
4. **Phase 4:** Add menu system and keyboard shortcuts
5. **Phase 5:** Advanced features (updater, drag tracking, window effects)
**Principle:** Port incrementally as features are needed, don't front-load everything.

BIN
bun.lockb
View File

Binary file not shown.