mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 06:42:03 +00:00
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.
This commit is contained in:
@@ -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() {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<FileBrowserProvider>
|
||||
<HomeContent />
|
||||
</FileBrowserProvider>
|
||||
);
|
||||
}
|
||||
|
||||
231
apps/app/src/components/dialogs/file-browser-dialog.tsx
Normal file
231
apps/app/src/components/dialogs/file-browser-dialog.tsx
Normal file
@@ -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<string>("");
|
||||
const [parentPath, setParentPath] = useState<string | null>(null);
|
||||
const [directories, setDirectories] = useState<DirectoryEntry[]>([]);
|
||||
const [drives, setDrives] = useState<string[]>([]);
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-popover border-border max-w-2xl max-h-[80vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5 text-brand-500" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-3 min-h-[400px]">
|
||||
{/* Drives selector (Windows only) */}
|
||||
{drives.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mr-2">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
<span>Drives:</span>
|
||||
</div>
|
||||
{drives.map((drive) => (
|
||||
<Button
|
||||
key={drive}
|
||||
variant={currentPath.startsWith(drive) ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleSelectDrive(drive)}
|
||||
className="h-7 px-3 text-xs"
|
||||
disabled={loading}
|
||||
>
|
||||
{drive.replace("\\", "")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current path breadcrumb */}
|
||||
<div className="flex items-center gap-2 p-3 rounded-lg bg-sidebar-accent/10 border border-sidebar-border">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoHome}
|
||||
className="h-7 px-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
</Button>
|
||||
{parentPath && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoToParent}
|
||||
className="h-7 px-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1 font-mono text-sm truncate text-muted-foreground">
|
||||
{currentPath || "Loading..."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory list */}
|
||||
<div className="flex-1 overflow-y-auto border border-sidebar-border rounded-lg">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-muted-foreground">Loading directories...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-destructive">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && directories.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full p-8">
|
||||
<div className="text-sm text-muted-foreground">No subdirectories found</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && directories.length > 0 && (
|
||||
<div className="divide-y divide-sidebar-border">
|
||||
{directories.map((dir) => (
|
||||
<button
|
||||
key={dir.path}
|
||||
onClick={() => handleSelectDirectory(dir)}
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-sidebar-accent/10 transition-colors text-left group"
|
||||
>
|
||||
<Folder className="w-5 h-5 text-brand-500 shrink-0" />
|
||||
<span className="flex-1 truncate text-sm">{dir.name}</span>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Click on a folder to navigate. Select the current folder or navigate to a subfolder.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSelect} disabled={!currentPath || loading}>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
Select Current Folder
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
68
apps/app/src/contexts/file-browser-context.tsx
Normal file
68
apps/app/src/contexts/file-browser-context.tsx
Normal file
@@ -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<string | null>;
|
||||
}
|
||||
|
||||
const FileBrowserContext = createContext<FileBrowserContextValue | null>(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<string | null> => {
|
||||
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 (
|
||||
<FileBrowserContext.Provider value={{ openFileBrowser }}>
|
||||
{children}
|
||||
<FileBrowserDialog
|
||||
open={isOpen}
|
||||
onOpenChange={handleOpenChange}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</FileBrowserContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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<string | null>) | null = null;
|
||||
|
||||
export function setGlobalFileBrowser(fn: () => Promise<string | null>) {
|
||||
globalFileBrowserFn = fn;
|
||||
}
|
||||
|
||||
export function getGlobalFileBrowser() {
|
||||
return globalFileBrowserFn;
|
||||
}
|
||||
@@ -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<DialogResult> {
|
||||
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<DialogResult> {
|
||||
// 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: [] };
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string[]> => {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user