From 0bb774375e4e6c1b6f701d854f5159108dc7b31a Mon Sep 17 00:00:00 2001 From: Kacper Date: Fri, 12 Dec 2025 19:20:32 +0100 Subject: [PATCH] feat: implement file browser context and dialog for directory selection - Introduced a new FileBrowserProvider to manage file browsing state and functionality. - Added FileBrowserDialog component for user interface to navigate and select directories. - Updated Home component to utilize the file browser context and provide global access. - Enhanced HttpApiClient to use the new file browser for directory and file selection. - Implemented server-side route for browsing directories, including drive detection on Windows. --- apps/app/src/app/page.tsx | 17 +- .../dialogs/file-browser-dialog.tsx | 231 ++++++++++++++++++ .../app/src/contexts/file-browser-context.tsx | 68 ++++++ apps/app/src/lib/http-api-client.ts | 29 ++- apps/server/src/routes/fs.ts | 77 ++++++ 5 files changed, 415 insertions(+), 7 deletions(-) create mode 100644 apps/app/src/components/dialogs/file-browser-dialog.tsx create mode 100644 apps/app/src/contexts/file-browser-context.tsx diff --git a/apps/app/src/app/page.tsx b/apps/app/src/app/page.tsx index e2260c86..14e200be 100644 --- a/apps/app/src/app/page.tsx +++ b/apps/app/src/app/page.tsx @@ -15,8 +15,9 @@ import { RunningAgentsView } from "@/components/views/running-agents-view"; import { useAppStore } from "@/store/app-store"; import { useSetupStore } from "@/store/setup-store"; import { getElectronAPI, isElectron } from "@/lib/electron"; +import { FileBrowserProvider, useFileBrowser, setGlobalFileBrowser } from "@/contexts/file-browser-context"; -export default function Home() { +function HomeContent() { const { currentView, setCurrentView, @@ -27,6 +28,7 @@ export default function Home() { const { isFirstRun, setupComplete } = useSetupStore(); const [isMounted, setIsMounted] = useState(false); const [streamerPanelOpen, setStreamerPanelOpen] = useState(false); + const { openFileBrowser } = useFileBrowser(); // Hidden streamer panel - opens with "\" key const handleStreamerPanelShortcut = useCallback((event: KeyboardEvent) => { @@ -79,6 +81,11 @@ export default function Home() { setIsMounted(true); }, []); + // Initialize global file browser for HttpApiClient + useEffect(() => { + setGlobalFileBrowser(openFileBrowser); + }, [openFileBrowser]); + // Check if this is first run and redirect to setup if needed useEffect(() => { console.log("[Setup Flow] Checking setup state:", { @@ -236,3 +243,11 @@ export default function Home() { ); } + +export default function Home() { + return ( + + + + ); +} diff --git a/apps/app/src/components/dialogs/file-browser-dialog.tsx b/apps/app/src/components/dialogs/file-browser-dialog.tsx new file mode 100644 index 00000000..29c183f1 --- /dev/null +++ b/apps/app/src/components/dialogs/file-browser-dialog.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { FolderOpen, Folder, ChevronRight, Home, ArrowLeft, HardDrive } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +interface DirectoryEntry { + name: string; + path: string; +} + +interface BrowseResult { + success: boolean; + currentPath: string; + parentPath: string | null; + directories: DirectoryEntry[]; + drives?: string[]; + error?: string; +} + +interface FileBrowserDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSelect: (path: string) => void; + title?: string; + description?: string; +} + +export function FileBrowserDialog({ + open, + onOpenChange, + onSelect, + title = "Select Project Directory", + description = "Navigate to your project folder", +}: FileBrowserDialogProps) { + const [currentPath, setCurrentPath] = useState(""); + const [parentPath, setParentPath] = useState(null); + const [directories, setDirectories] = useState([]); + const [drives, setDrives] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const browseDirectory = async (dirPath?: string) => { + setLoading(true); + setError(""); + + try { + // Get server URL from environment or default + const serverUrl = process.env.NEXT_PUBLIC_SERVER_URL || "http://localhost:3008"; + + const response = await fetch(`${serverUrl}/api/fs/browse`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dirPath }), + }); + + const result: BrowseResult = await response.json(); + + if (result.success) { + setCurrentPath(result.currentPath); + setParentPath(result.parentPath); + setDirectories(result.directories); + setDrives(result.drives || []); + } else { + setError(result.error || "Failed to browse directory"); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load directories"); + } finally { + setLoading(false); + } + }; + + // Load home directory on mount + useEffect(() => { + if (open && !currentPath) { + browseDirectory(); + } + }, [open]); + + const handleSelectDirectory = (dir: DirectoryEntry) => { + browseDirectory(dir.path); + }; + + const handleGoToParent = () => { + if (parentPath) { + browseDirectory(parentPath); + } + }; + + const handleGoHome = () => { + browseDirectory(); + }; + + const handleSelectDrive = (drivePath: string) => { + browseDirectory(drivePath); + }; + + const handleSelect = () => { + if (currentPath) { + onSelect(currentPath); + onOpenChange(false); + } + }; + + return ( + + + + + + {title} + + + {description} + + + +
+ {/* Drives selector (Windows only) */} + {drives.length > 0 && ( +
+
+ + Drives: +
+ {drives.map((drive) => ( + + ))} +
+ )} + + {/* Current path breadcrumb */} +
+ + {parentPath && ( + + )} +
+ {currentPath || "Loading..."} +
+
+ + {/* Directory list */} +
+ {loading && ( +
+
Loading directories...
+
+ )} + + {error && ( +
+
{error}
+
+ )} + + {!loading && !error && directories.length === 0 && ( +
+
No subdirectories found
+
+ )} + + {!loading && !error && directories.length > 0 && ( +
+ {directories.map((dir) => ( + + ))} +
+ )} +
+ +
+ Click on a folder to navigate. Select the current folder or navigate to a subfolder. +
+
+ + + + + +
+
+ ); +} diff --git a/apps/app/src/contexts/file-browser-context.tsx b/apps/app/src/contexts/file-browser-context.tsx new file mode 100644 index 00000000..f54fb27f --- /dev/null +++ b/apps/app/src/contexts/file-browser-context.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; +import { FileBrowserDialog } from "@/components/dialogs/file-browser-dialog"; + +interface FileBrowserContextValue { + openFileBrowser: () => Promise; +} + +const FileBrowserContext = createContext(null); + +export function FileBrowserProvider({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const [resolver, setResolver] = useState<((value: string | null) => void) | null>(null); + + const openFileBrowser = useCallback((): Promise => { + return new Promise((resolve) => { + setIsOpen(true); + setResolver(() => resolve); + }); + }, []); + + const handleSelect = useCallback((path: string) => { + if (resolver) { + resolver(path); + setResolver(null); + } + setIsOpen(false); + }, [resolver]); + + const handleOpenChange = useCallback((open: boolean) => { + if (!open && resolver) { + resolver(null); + setResolver(null); + } + setIsOpen(open); + }, [resolver]); + + return ( + + {children} + + + ); +} + +export function useFileBrowser() { + const context = useContext(FileBrowserContext); + if (!context) { + throw new Error("useFileBrowser must be used within FileBrowserProvider"); + } + return context; +} + +// Global reference for non-React code (like HttpApiClient) +let globalFileBrowserFn: (() => Promise) | null = null; + +export function setGlobalFileBrowser(fn: () => Promise) { + globalFileBrowserFn = fn; +} + +export function getGlobalFileBrowser() { + return globalFileBrowserFn; +} diff --git a/apps/app/src/lib/http-api-client.ts b/apps/app/src/lib/http-api-client.ts index 1437d152..76313ee1 100644 --- a/apps/app/src/lib/http-api-client.ts +++ b/apps/app/src/lib/http-api-client.ts @@ -31,6 +31,7 @@ import type { ModelDefinition, ProviderStatus, } from "@/types/electron"; +import { getGlobalFileBrowser } from "@/contexts/file-browser-context"; // Server URL - configurable via environment variable @@ -201,9 +202,17 @@ export class HttpApiClient implements ElectronAPI { return { success: true }; } - // File picker - uses prompt for path input + // File picker - uses server-side file browser dialog async openDirectory(): Promise { - const path = prompt("Enter project directory path:"); + const fileBrowser = getGlobalFileBrowser(); + + if (!fileBrowser) { + console.error("File browser not initialized"); + return { canceled: true, filePaths: [] }; + } + + const path = await fileBrowser(); + if (!path) { return { canceled: true, filePaths: [] }; } @@ -219,13 +228,21 @@ export class HttpApiClient implements ElectronAPI { return { canceled: false, filePaths: [result.path] }; } - alert(result.error || "Invalid path"); + console.error("Invalid directory:", result.error); return { canceled: true, filePaths: [] }; } async openFile(options?: object): Promise { - // Prompt for file path - const path = prompt("Enter file path:"); + const fileBrowser = getGlobalFileBrowser(); + + if (!fileBrowser) { + console.error("File browser not initialized"); + return { canceled: true, filePaths: [] }; + } + + // For now, use the same directory browser (could be enhanced for file selection) + const path = await fileBrowser(); + if (!path) { return { canceled: true, filePaths: [] }; } @@ -239,7 +256,7 @@ export class HttpApiClient implements ElectronAPI { return { canceled: false, filePaths: [path] }; } - alert("File does not exist"); + console.error("File not found"); return { canceled: true, filePaths: [] }; } diff --git a/apps/server/src/routes/fs.ts b/apps/server/src/routes/fs.ts index 6258fce7..64d0d796 100644 --- a/apps/server/src/routes/fs.ts +++ b/apps/server/src/routes/fs.ts @@ -6,6 +6,7 @@ import { Router, type Request, type Response } from "express"; import fs from "fs/promises"; import path from "path"; +import os from "os"; import { validatePath, addAllowedPath, isPathAllowed } from "../lib/security.js"; import type { EventEmitter } from "../lib/events.js"; @@ -263,6 +264,82 @@ export function createFsRoutes(_events: EventEmitter): Router { } }); + // Browse directories - for file browser UI + router.post("/browse", async (req: Request, res: Response) => { + try { + const { dirPath } = req.body as { dirPath?: string }; + + // Default to home directory if no path provided + const targetPath = dirPath ? path.resolve(dirPath) : os.homedir(); + + // Detect available drives on Windows + const detectDrives = async (): Promise => { + if (os.platform() !== "win32") { + return []; + } + + const drives: string[] = []; + const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + for (const letter of letters) { + const drivePath = `${letter}:\\`; + try { + await fs.access(drivePath); + drives.push(drivePath); + } catch { + // Drive doesn't exist, skip it + } + } + + return drives; + }; + + try { + const stats = await fs.stat(targetPath); + + if (!stats.isDirectory()) { + res.status(400).json({ success: false, error: "Path is not a directory" }); + return; + } + + // Read directory contents + const entries = await fs.readdir(targetPath, { withFileTypes: true }); + + // Filter for directories only and add parent directory option + const directories = entries + .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .map((entry) => ({ + name: entry.name, + path: path.join(targetPath, entry.name), + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + // Get parent directory + const parentPath = path.dirname(targetPath); + const hasParent = parentPath !== targetPath; + + // Get available drives + const drives = await detectDrives(); + + res.json({ + success: true, + currentPath: targetPath, + parentPath: hasParent ? parentPath : null, + directories, + drives, + }); + } catch (error) { + res.status(400).json({ + success: false, + error: error instanceof Error ? error.message : "Failed to read directory", + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + // Serve image files router.get("/image", async (req: Request, res: Response) => { try {