mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-25 00:35:02 -04:00
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:
@@ -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).
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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' ? (
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { RootNavigator } from "./RootNavigator";
|
||||
export type * from "./types";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
71
apps/mobile/src/screens/browse/components/SpaceGroup.tsx
Normal file
71
apps/mobile/src/screens/browse/components/SpaceGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
224
apps/mobile/src/screens/browse/components/SpaceItem.tsx
Normal file
224
apps/mobile/src/screens/browse/components/SpaceItem.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { DevicesGroup } from "./DevicesGroup";
|
||||
export { LocationsGroup } from "./LocationsGroup";
|
||||
export { VolumesGroup } from "./VolumesGroup";
|
||||
export { SpaceItem } from "./SpaceItem";
|
||||
export { SpaceGroupComponent } from "./SpaceGroup";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user