diff --git a/.changeset/add-open-in-file-manager-button.md b/.changeset/add-open-in-file-manager-button.md new file mode 100644 index 00000000..f2959fde --- /dev/null +++ b/.changeset/add-open-in-file-manager-button.md @@ -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. diff --git a/packages/file-system-plugin/docs/OPEN_IN_FILE_MANAGER.md b/packages/file-system-plugin/docs/OPEN_IN_FILE_MANAGER.md new file mode 100644 index 00000000..f1a24bae --- /dev/null +++ b/packages/file-system-plugin/docs/OPEN_IN_FILE_MANAGER.md @@ -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 ""` 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 ""`. +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. diff --git a/packages/file-system-plugin/src/file-system.tsx b/packages/file-system-plugin/src/file-system.tsx index 44aadc31..1308cb70 100644 --- a/packages/file-system-plugin/src/file-system.tsx +++ b/packages/file-system-plugin/src/file-system.tsx @@ -33,24 +33,64 @@ export default function FileSystemPanel() { const [importLoading, setImportLoading] = useState(false); const [exportPath, setExportPath] = useState(null); const [transferError, setTransferError] = useState(null); + const [revealStatus, setRevealStatus] = useState(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 }) => ( + + {revealStatus ? ( + + {revealStatus} + + ) : null} @@ -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', + }, }); diff --git a/packages/file-system-plugin/src/react-native/useFileSystemDevTools.ts b/packages/file-system-plugin/src/react-native/useFileSystemDevTools.ts index 02f117d0..3764f401 100644 --- a/packages/file-system-plugin/src/react-native/useFileSystemDevTools.ts +++ b/packages/file-system-plugin/src/react-native/useFileSystemDevTools.ts @@ -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'; @@ -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, ) => { @@ -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 = []; diff --git a/packages/file-system-plugin/src/shared/protocol.ts b/packages/file-system-plugin/src/shared/protocol.ts index 8c63e4ff..5e833136 100644 --- a/packages/file-system-plugin/src/shared/protocol.ts +++ b/packages/file-system-plugin/src/shared/protocol.ts @@ -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; + }; }; diff --git a/packages/file-system-plugin/src/ui/DetailPanel.tsx b/packages/file-system-plugin/src/ui/DetailPanel.tsx index a113e2e5..9a7cd468 100644 --- a/packages/file-system-plugin/src/ui/DetailPanel.tsx +++ b/packages/file-system-plugin/src/ui/DetailPanel.tsx @@ -26,6 +26,7 @@ type DetailPanelProps = { requestImagePreview: FileSystemRequests['requestImagePreview']; requestTextPreview: FileSystemRequests['requestTextPreview']; onExport: (entry: FsEntry) => void; + onRevealInFileManager: (entry: FsEntry) => void; }; export function DetailPanel({ @@ -35,6 +36,7 @@ export function DetailPanel({ requestImagePreview, requestTextPreview, onExport, + onRevealInFileManager, }: DetailPanelProps) { const [imagePreviewUri, setImagePreviewUri] = useState(null); const [imagePreviewError, setImagePreviewError] = useState( @@ -121,22 +123,35 @@ export function DetailPanel({ {selected.name} - [ - styles.exportButton, - state.hovered && - canExport && - !exportLoading && - styles.exportButtonHovered, - (!canExport || exportLoading) && styles.exportButtonDisabled, - ]} - onPress={() => onExport(selected)} - disabled={!canExport || exportLoading} - > - - {exportLoading ? 'Exporting…' : 'Export'} - - + + [ + styles.revealButton, + state.hovered && styles.revealButtonHovered, + ]} + onPress={() => onRevealInFileManager(selected)} + > + Open in File Manager + + + {canExport && ( + [ + styles.exportButton, + state.hovered && + !exportLoading && + styles.exportButtonHovered, + exportLoading && styles.exportButtonDisabled, + ]} + onPress={() => onExport(selected)} + disabled={exportLoading} + > + + {exportLoading ? 'Exporting…' : 'Export'} + + + )} + void >; + revealInFileManager: Map< + string, + (payload: FileSystemEventMap['fs:reveal-in-file-manager:result']) => void + >; }; export function useFileSystemRequests( @@ -37,6 +41,7 @@ export function useFileSystemRequests( file: new Map(), exportFile: new Map(), importFile: new Map(), + revealInFileManager: new Map(), }); useEffect(() => { @@ -96,6 +101,19 @@ export function useFileSystemRequests( }, ); + const subRevealInFileManager = client.onMessage( + 'fs:reveal-in-file-manager:result', + (payload) => { + const resolve = pendingRef.current.revealInFileManager.get( + payload.requestId, + ); + if (resolve) { + pendingRef.current.revealInFileManager.delete(payload.requestId); + resolve(payload); + } + }, + ); + return () => { subRoots.remove(); subList.remove(); @@ -103,6 +121,7 @@ export function useFileSystemRequests( subFile.remove(); subExportFile.remove(); subImportFile.remove(); + subRevealInFileManager.remove(); }; }, [client]); @@ -202,6 +221,25 @@ export function useFileSystemRequests( [client], ); + const requestRevealInFileManager = useCallback( + async (path: string) => { + if (!client) return null; + const requestId = newRequestId(); + const p = new Promise< + FileSystemEventMap['fs:reveal-in-file-manager:result'] + >((resolve) => { + pendingRef.current.revealInFileManager.set(requestId, resolve); + }); + client.send('fs:reveal-in-file-manager', { requestId, path }); + return await withTimeout( + p, + 10000, + 'Timeout revealing in file manager', + ); + }, + [client], + ); + return { requestRoots, requestList, @@ -209,5 +247,6 @@ export function useFileSystemRequests( requestTextPreview, requestExportFile, requestImportFile, + requestRevealInFileManager, }; } diff --git a/packages/middleware/src/middleware.ts b/packages/middleware/src/middleware.ts index 26787876..ab3144ba 100644 --- a/packages/middleware/src/middleware.ts +++ b/packages/middleware/src/middleware.ts @@ -3,6 +3,8 @@ import assert from 'node:assert'; import path from 'node:path'; import fs from 'node:fs'; import { createRequire } from 'node:module'; +import { exec } from 'node:child_process'; +import { promisify } from 'node:util'; import { getEntryPointHTML } from './entry-point.js'; import { InstalledPlugin } from './auto-discovery.js'; import { getReactNativeDebuggerFrontendPath } from './resolve.js'; @@ -11,6 +13,39 @@ import { logger } from './logger.js'; import type { AgentSessionManager } from './agent/index.js'; import { createAgentRoutes } from './agent/index.js'; +const execPromise = promisify(exec); + +async function openInFileManager( + targetPath: string, +): Promise<{ success: boolean; error?: string }> { + let cleanPath = targetPath; + if (cleanPath.startsWith('file://')) { + cleanPath = cleanPath.slice(7); + } + + // basic command injection check + const safePath = cleanPath.replace(/"/g, '\\"'); + + let command = ''; + if (process.platform === 'darwin') { + command = `open -R "${safePath}"`; + } else if (process.platform === 'win32') { + command = `explorer.exe /select,"${safePath}"`; + } else { + command = `xdg-open "${safePath}"`; + } + + try { + await execPromise(command); + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + const require = createRequire(import.meta.url); export type MiddlewareConfig = { @@ -97,6 +132,23 @@ export const getMiddleware = ( res.end(fs.readFileSync(path.join(frameworkPath, 'host.js'), 'utf8')); }); + app.post('/open-in-file-manager', express.json(), async (req, res) => { + const { path } = req.body; + if (typeof path !== 'string') { + res.status(400).send('Path is required'); + return; + } + + logger.debug(`Opening path in host file manager: ${path}`); + const result = await openInFileManager(path); + if (result.success) { + res.status(200).json({ success: true }); + } else { + logger.error(`Failed to open path in host file manager: ${result.error}`); + res.status(500).json({ success: false, error: result.error }); + } + }); + app.use(createAgentRoutes(agentSessionManager)); app.use(express.static(debuggerFrontend));