Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/add-open-in-file-manager-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@rozenite/file-system-plugin": minor
"@rozenite/middleware": minor
---

Add "Open in File Manager" button in the Details Panel to reveal files/folders in the host desktop file manager (Finder/Explorer), with a device-side fallback.
61 changes: 61 additions & 0 deletions packages/file-system-plugin/docs/OPEN_IN_FILE_MANAGER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Open in File Manager - Developer Documentation

This document describes the design, architecture, and behavior of the **"Open in File Manager"** feature in the Rozenite DevTools File System Plugin.

---

## 1. Feature Overview

The **"Open in File Manager"** feature allows developers debugging React Native/Expo apps to instantly reveal any selected file or directory inside their computer's native file manager (e.g., **Finder** on macOS, **File Explorer** on Windows, or **Files** on Linux) directly from the DevTools details panel.

---

## 2. Architecture & Flow

The feature spans across the DevTools Web frontend, the host server middleware, and the simulator/device fallback bridge.

```mermaid
sequenceDiagram
participant WebUI as DevTools Panel (Browser)
participant HostServer as DevTools Host Middleware (Node)
participant Agent as React Native Runtime (App)

WebUI->>HostServer: POST /rozenite/open-in-file-manager { path }
alt Desktop Host File Exists (e.g. iOS Sim / Android Emulator)
HostServer->>HostServer: Spawn shell (open -R / explorer.exe)
HostServer-->>WebUI: 200 OK (Success)
else Path not found on Desktop (e.g. Physical Device)
HostServer-->>WebUI: 500 Error
WebUI->>Agent: Send WebSocket message "fs:reveal-in-file-manager"
Agent->>Agent: Linking.openURL() / Share.share()
Agent-->>WebUI: Result Callback
end
```

### Flow Steps:
1. **User Action**: The developer clicks the "Open in File Manager" button in the Detail Panel (when a file or directory is selected).
2. **Desktop Request**: The browser panel sends an HTTP POST request to the local DevTools server endpoint `/rozenite/open-in-file-manager` containing the path of the selected item.
3. **Execution**:
- **Darwin (macOS)**: Runs `open -R "<path>"` to open the parent directory and highlight the file/directory in Finder.
- **Win32 (Windows)**: Runs `explorer.exe /select,"${safePath}"` to reveal the item in File Explorer.
- **Linux / Other**: Falls back to `xdg-open "<path>"`.
4. **Fallback**: If the endpoint fails or throws (e.g. because the path is on a physical device sandboxed file system and doesn't exist on the desktop), the panel falls back to the React Native app's native `Linking`/`Share` API handler to open it on the device/simulator context.

---

## 3. Directory Navigation & Selection Integration

### Selection Behavior:
- **Clicking a Directory**: Automatically sets the directory as `selected` AND navigates into it (`nav.setCurrentPath`).
- **Persistence**: The active directory selection persists during navigation to keep the "Open in File Manager" button visible for that folder. Navigating up/back or selecting a different file clears the previous directory selection.

---

## 4. Modified Files Reference

- **[middleware.ts](file:///Users/ggipl/Downloads/rozenite-main/packages/middleware/src/middleware.ts)**: Registers the POST `/open-in-file-manager` endpoint and executes host-specific shell commands to reveal the path.
- **[file-system.tsx](file:///Users/ggipl/Downloads/rozenite-main/packages/file-system-plugin/src/file-system.tsx)**: Handles the click-to-select-and-navigate logic and handles fetch requests to the host with a graceful device fallback.
- **[DetailPanel.tsx](file:///Users/ggipl/Downloads/rozenite-main/packages/file-system-plugin/src/ui/DetailPanel.tsx)**: Displays the "Open in File Manager" button inside the details block next to the "Export" button.
- **[FileEntryRow.tsx](file:///Users/ggipl/Downloads/rozenite-main/packages/file-system-plugin/src/ui/FileEntryRow.tsx)**: Reverted double-click hooks to return file entries to standard single-click items.
- **[useFileSystemDevTools.ts](file:///Users/ggipl/Downloads/rozenite-main/packages/file-system-plugin/src/react-native/useFileSystemDevTools.ts)**: Handles the device-side WebSocket message fallback action.
- **[protocol.ts](file:///Users/ggipl/Downloads/rozenite-main/packages/file-system-plugin/src/shared/protocol.ts)**: Declares the WebSocket communication events.
75 changes: 70 additions & 5 deletions packages/file-system-plugin/src/file-system.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,64 @@ export default function FileSystemPanel() {
const [importLoading, setImportLoading] = useState(false);
const [exportPath, setExportPath] = useState<string | null>(null);
const [transferError, setTransferError] = useState<string | null>(null);
const [revealStatus, setRevealStatus] = useState<string | null>(null);

// Clear selection when the directory changes (preserves original loadDir behavior)
// Clear selection when the directory changes, except when entering the currently selected directory
useEffect(() => {
setSelected(null);
if (selected && selected.path !== nav.currentPath) {
setSelected(null);
}
}, [nav.currentPath]);

const onSelectEntry = useCallback(
(entry: FsEntry) => {
setSelected(entry);
if (entry.isDirectory) {
setSelected(null);
nav.setCurrentPath(entry.path);
return;
}
setSelected(entry);
},
[nav.setCurrentPath],
);

const onRevealInFileManager = useCallback(
async (entry: FsEntry) => {
setRevealStatus('Opening in file manager…');

const triggerDeviceFallback = async () => {
const res = await requests.requestRevealInFileManager(entry.path);
if (res?.error) {
throw new Error(res.error);
}
};

try {
const response = await fetch('/rozenite/open-in-file-manager', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ path: entry.path }),
});

if (!response.ok) {
await triggerDeviceFallback();
}
setRevealStatus('✓ Revealed in file manager');
} catch (e) {
try {
await triggerDeviceFallback();
setRevealStatus('✓ Revealed in file manager');
} catch (fallbackErr) {
setRevealStatus(
`⚠ ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}`,
);
}
}
setTimeout(() => setRevealStatus(null), 3000);
},
[requests],
);

const renderItem = useCallback(
({ item }: { item: FsEntry }) => (
<FileEntryRow
Expand Down Expand Up @@ -250,6 +290,12 @@ export default function FileSystemPanel() {
contentContainerStyle={styles.listContent}
renderItem={renderItem}
/>

{revealStatus ? (
<View style={styles.revealToast}>
<Text style={styles.revealToastText}>{revealStatus}</Text>
</View>
) : null}
</View>

<DetailPanel
Expand All @@ -263,6 +309,7 @@ export default function FileSystemPanel() {
requestImagePreview={requests.requestImagePreview}
requestTextPreview={requests.requestTextPreview}
onExport={onExport}
onRevealInFileManager={onRevealInFileManager}
/>
</View>
</SafeAreaView>
Expand Down Expand Up @@ -337,4 +384,22 @@ const styles = StyleSheet.create({
color: '#ffb3c1',
fontSize: 12,
},
revealToast: {
position: 'absolute',
bottom: 12,
left: 12,
right: 12,
paddingHorizontal: 14,
paddingVertical: 10,
borderRadius: 10,
backgroundColor: 'rgba(130, 50, 255, 0.18)',
borderWidth: 1,
borderColor: 'rgba(130, 50, 255, 0.35)',
},
revealToastText: {
color: '#d4b8ff',
fontSize: 12,
fontWeight: '600',
textAlign: 'center',
},
});
104 changes: 104 additions & 0 deletions packages/file-system-plugin/src/react-native/useFileSystemDevTools.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
import { Linking, Platform, Share } from 'react-native';
import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
import type { FileSystemEventMap } from '../shared/protocol';
import { PLUGIN_ID } from '../shared/protocol';
Expand All @@ -13,6 +14,19 @@ import { useFileSystemAgentTools } from './useFileSystemAgentTools';

export type { UseFileSystemDevToolsOptions } from './fileSystemProvider';

/**
* Returns the parent directory path for a given file path.
* If the path is already a directory (ends with '/'), returns it as-is.
*/
function getParentDirectoryPath(filePath: string): string {
const cleanPath = filePath.endsWith('/')
? filePath.slice(0, -1)
: filePath;
const lastSlash = cleanPath.lastIndexOf('/');
if (lastSlash <= 0) return '/';
return cleanPath.slice(0, lastSlash + 1);
}

export const useFileSystemDevTools = (
options?: UseFileSystemDevToolsOptions,
) => {
Expand Down Expand Up @@ -297,6 +311,96 @@ export const useFileSystemDevTools = (
),
);

subsRef.current.push(
client.onMessage(
'fs:reveal-in-file-manager',
async ({ requestId, path }) => {
try {
// For directories, open the directory itself.
// For files, open the parent directory.
const isDirectory = path.endsWith('/');
const targetPath = isDirectory
? path
: getParentDirectoryPath(path);

// Build a file:// URL
const fileUrl = targetPath.startsWith('file://')
? targetPath
: `file://${targetPath}`;

if (Platform.OS === 'ios' || Platform.OS === 'macos') {
// On iOS, try Linking first, then fall back to Share
try {
const canOpen = await Linking.canOpenURL(fileUrl);
if (canOpen) {
await Linking.openURL(fileUrl);
client.send('fs:reveal-in-file-manager:result', {
requestId,
path,
});
return;
}
} catch {
// Linking failed, try Share as fallback
}

// Fallback: use the Share sheet so the user can open in Files app
try {
await Share.share({
url: fileUrl,
title: `Reveal: ${path}`,
});
client.send('fs:reveal-in-file-manager:result', {
requestId,
path,
});
} catch (shareError) {
client.send('fs:reveal-in-file-manager:result', {
requestId,
path,
error: `Could not reveal file: ${safeError(shareError)}`,
});
}
} else if (Platform.OS === 'android') {
// On Android, use an ACTION_VIEW intent via Linking
try {
const contentUrl = `content://${targetPath}`;
const canOpen = await Linking.canOpenURL(contentUrl);
if (canOpen) {
await Linking.openURL(contentUrl);
} else {
await Linking.openURL(fileUrl);
}
client.send('fs:reveal-in-file-manager:result', {
requestId,
path,
});
} catch (androidError) {
client.send('fs:reveal-in-file-manager:result', {
requestId,
path,
error: `Could not reveal file on Android: ${safeError(androidError)}`,
});
}
} else {
// Unsupported platform
client.send('fs:reveal-in-file-manager:result', {
requestId,
path,
error: `Reveal in file manager is not supported on platform "${Platform.OS}". Path: ${path}`,
});
}
} catch (e) {
client.send('fs:reveal-in-file-manager:result', {
requestId,
path,
error: safeError(e),
});
}
},
),
);

return () => {
subsRef.current.forEach((s) => s.remove());
subsRef.current = [];
Expand Down
7 changes: 7 additions & 0 deletions packages/file-system-plugin/src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,11 @@ export type FileSystemEventMap = {
overwriteRequired?: boolean;
error?: string;
};

"fs:reveal-in-file-manager": { requestId: string; path: string };
"fs:reveal-in-file-manager:result": {
requestId: string;
path: string;
error?: string;
};
};
Loading