Initial open-source release
This commit is contained in:
79
src/components/preview_panel/CodeView.tsx
Normal file
79
src/components/preview_panel/CodeView.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useState } from "react";
|
||||
import { FileEditor } from "./FileEditor";
|
||||
import { FileTree } from "./FileTree";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { selectedFileAtom } from "@/atoms/viewAtoms";
|
||||
|
||||
interface App {
|
||||
id?: number;
|
||||
files?: string[];
|
||||
}
|
||||
|
||||
export interface CodeViewProps {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
app: App | null;
|
||||
}
|
||||
|
||||
// Code view component that displays app files or status messages
|
||||
export const CodeView = ({ loading, error, app }: CodeViewProps) => {
|
||||
const selectedFile = useAtomValue(selectedFileAtom);
|
||||
const { refreshApp } = useLoadApp(app?.id ?? null);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-4">Loading files...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-4 text-red-500">
|
||||
Error loading files: {error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!app) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500">No app selected</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (app.files && app.files.length > 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center p-2 border-b space-x-2">
|
||||
<button
|
||||
onClick={refreshApp}
|
||||
className="p-1 rounded hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading || !app.id}
|
||||
title="Refresh Files"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<div className="text-sm text-gray-500">{app.files.length} files</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="w-1/3 overflow-auto border-r">
|
||||
<FileTree files={app.files} />
|
||||
</div>
|
||||
<div className="w-2/3">
|
||||
{selectedFile ? (
|
||||
<FileEditor appId={app.id ?? null} filePath={selectedFile.path} />
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
Select a file to view
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="text-center py-4 text-gray-500">No files found</div>;
|
||||
};
|
||||
14
src/components/preview_panel/Console.tsx
Normal file
14
src/components/preview_panel/Console.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { appOutputAtom } from "@/atoms/appAtoms";
|
||||
import { useAtomValue } from "jotai";
|
||||
|
||||
// Console component
|
||||
export const Console = () => {
|
||||
const appOutput = useAtomValue(appOutputAtom);
|
||||
return (
|
||||
<div className="font-mono text-xs px-4 h-full overflow-auto">
|
||||
{appOutput.map((output, index) => (
|
||||
<div key={index}>{output.message}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
205
src/components/preview_panel/FileEditor.tsx
Normal file
205
src/components/preview_panel/FileEditor.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import Editor, { OnMount } from "@monaco-editor/react";
|
||||
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
||||
import { useTheme } from "@/contexts/ThemeContext";
|
||||
import { ChevronRight, Circle } from "lucide-react";
|
||||
import "@/components/chat/monaco";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
|
||||
interface FileEditorProps {
|
||||
appId: number | null;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
interface BreadcrumbProps {
|
||||
path: string;
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
const Breadcrumb: React.FC<BreadcrumbProps> = ({ path, hasUnsavedChanges }) => {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-600 dark:text-gray-300 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-1 overflow-hidden">
|
||||
<div className="flex items-center gap-1 overflow-hidden min-w-0">
|
||||
{segments.map((segment, index) => (
|
||||
<React.Fragment key={index}>
|
||||
{index > 0 && (
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="text-gray-400 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer truncate">
|
||||
{segment}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-shrink-0 ml-2">
|
||||
{hasUnsavedChanges && (
|
||||
<Circle
|
||||
size={8}
|
||||
fill="currentColor"
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FileEditor = ({ appId, filePath }: FileEditorProps) => {
|
||||
const { content, loading, error } = useLoadAppFile(appId, filePath);
|
||||
const { theme } = useTheme();
|
||||
const [value, setValue] = useState<string | undefined>(undefined);
|
||||
const [displayUnsavedChanges, setDisplayUnsavedChanges] = useState(false);
|
||||
|
||||
// Use refs for values that need to be current in event handlers
|
||||
const originalValueRef = useRef<string | undefined>(undefined);
|
||||
const editorRef = useRef<any>(null);
|
||||
const isSavingRef = useRef<boolean>(false);
|
||||
const needsSaveRef = useRef<boolean>(false);
|
||||
const currentValueRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Update state when content loads
|
||||
useEffect(() => {
|
||||
if (content !== null) {
|
||||
setValue(content);
|
||||
originalValueRef.current = content;
|
||||
currentValueRef.current = content;
|
||||
needsSaveRef.current = false;
|
||||
setDisplayUnsavedChanges(false);
|
||||
}
|
||||
}, [content, filePath]);
|
||||
|
||||
// Sync the UI with the needsSave ref
|
||||
useEffect(() => {
|
||||
setDisplayUnsavedChanges(needsSaveRef.current);
|
||||
}, [needsSaveRef.current]);
|
||||
|
||||
// Determine if dark mode based on theme
|
||||
const isDarkMode =
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
const editorTheme = isDarkMode ? "dyad-dark" : "dyad-light";
|
||||
|
||||
// Handle editor mount
|
||||
const handleEditorDidMount: OnMount = (editor, monaco) => {
|
||||
editorRef.current = editor;
|
||||
|
||||
// Listen for model content change events
|
||||
editor.onDidBlurEditorText(() => {
|
||||
console.log("Editor text blurred, checking if save needed");
|
||||
if (needsSaveRef.current) {
|
||||
saveFile();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Handle content change
|
||||
const handleEditorChange = (newValue: string | undefined) => {
|
||||
setValue(newValue);
|
||||
currentValueRef.current = newValue;
|
||||
|
||||
const hasChanged = newValue !== originalValueRef.current;
|
||||
needsSaveRef.current = hasChanged;
|
||||
setDisplayUnsavedChanges(hasChanged);
|
||||
};
|
||||
|
||||
// Save the file
|
||||
const saveFile = async () => {
|
||||
if (
|
||||
!appId ||
|
||||
!currentValueRef.current ||
|
||||
!needsSaveRef.current ||
|
||||
isSavingRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
isSavingRef.current = true;
|
||||
|
||||
const ipcClient = IpcClient.getInstance();
|
||||
await ipcClient.editAppFile(appId, filePath, currentValueRef.current);
|
||||
|
||||
originalValueRef.current = currentValueRef.current;
|
||||
needsSaveRef.current = false;
|
||||
setDisplayUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Error saving file:", error);
|
||||
// Could add error notification here
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Determine language based on file extension
|
||||
const getLanguage = (filePath: string) => {
|
||||
const extension = filePath.split(".").pop()?.toLowerCase() || "";
|
||||
const languageMap: Record<string, string> = {
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
html: "html",
|
||||
css: "css",
|
||||
json: "json",
|
||||
md: "markdown",
|
||||
py: "python",
|
||||
java: "java",
|
||||
c: "c",
|
||||
cpp: "cpp",
|
||||
cs: "csharp",
|
||||
go: "go",
|
||||
rs: "rust",
|
||||
rb: "ruby",
|
||||
php: "php",
|
||||
swift: "swift",
|
||||
kt: "kotlin",
|
||||
// Add more as needed
|
||||
};
|
||||
|
||||
return languageMap[extension] || "plaintext";
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4">Loading file content...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="p-4 text-red-500">Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return <div className="p-4 text-gray-500">No content available</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Breadcrumb path={filePath} hasUnsavedChanges={displayUnsavedChanges} />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Editor
|
||||
height="100%"
|
||||
defaultLanguage={getLanguage(filePath)}
|
||||
value={value}
|
||||
theme={editorTheme}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
minimap: { enabled: true },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: "on",
|
||||
automaticLayout: true,
|
||||
fontFamily: "monospace",
|
||||
fontSize: 13,
|
||||
lineNumbers: "on",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
127
src/components/preview_panel/FileTree.tsx
Normal file
127
src/components/preview_panel/FileTree.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from "react";
|
||||
import { Folder, FolderOpen } from "lucide-react";
|
||||
import { selectedFileAtom } from "@/atoms/viewAtoms";
|
||||
import { useSetAtom } from "jotai";
|
||||
|
||||
interface FileTreeProps {
|
||||
files: string[];
|
||||
}
|
||||
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
path: string;
|
||||
isDirectory: boolean;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
// Convert flat file list to tree structure
|
||||
const buildFileTree = (files: string[]): TreeNode[] => {
|
||||
const root: TreeNode[] = [];
|
||||
|
||||
files.forEach((path) => {
|
||||
const parts = path.split("/");
|
||||
let currentLevel = root;
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const isLastPart = index === parts.length - 1;
|
||||
const currentPath = parts.slice(0, index + 1).join("/");
|
||||
|
||||
// Check if this node already exists at the current level
|
||||
const existingNode = currentLevel.find((node) => node.name === part);
|
||||
|
||||
if (existingNode) {
|
||||
// If we found the node, just drill down to its children for the next level
|
||||
currentLevel = existingNode.children;
|
||||
} else {
|
||||
// Create a new node
|
||||
const newNode: TreeNode = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
isDirectory: !isLastPart,
|
||||
children: [],
|
||||
};
|
||||
|
||||
currentLevel.push(newNode);
|
||||
currentLevel = newNode.children;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return root;
|
||||
};
|
||||
|
||||
// File tree component
|
||||
export const FileTree = ({ files }: FileTreeProps) => {
|
||||
const treeData = buildFileTree(files);
|
||||
|
||||
return (
|
||||
<div className="file-tree mt-2">
|
||||
<TreeNodes nodes={treeData} level={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TreeNodesProps {
|
||||
nodes: TreeNode[];
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Sort nodes to show directories first
|
||||
const sortNodes = (nodes: TreeNode[]): TreeNode[] => {
|
||||
return [...nodes].sort((a, b) => {
|
||||
if (a.isDirectory === b.isDirectory) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return a.isDirectory ? -1 : 1;
|
||||
});
|
||||
};
|
||||
|
||||
// Tree nodes component
|
||||
const TreeNodes = ({ nodes, level }: TreeNodesProps) => (
|
||||
<ul className="ml-4">
|
||||
{sortNodes(nodes).map((node, index) => (
|
||||
<TreeNode key={index} node={node} level={level} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
interface TreeNodeProps {
|
||||
node: TreeNode;
|
||||
level: number;
|
||||
}
|
||||
|
||||
// Individual tree node component
|
||||
const TreeNode = ({ node, level }: TreeNodeProps) => {
|
||||
const [expanded, setExpanded] = React.useState(level < 2);
|
||||
const setSelectedFile = useSetAtom(selectedFileAtom);
|
||||
|
||||
const handleClick = () => {
|
||||
if (node.isDirectory) {
|
||||
setExpanded(!expanded);
|
||||
} else {
|
||||
setSelectedFile({
|
||||
path: node.path,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="py-0.5">
|
||||
<div
|
||||
className="flex items-center hover:bg-(--sidebar) rounded cursor-pointer px-1.5 py-0.5 text-sm"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{node.isDirectory && (
|
||||
<span className="mr-1 text-gray-500">
|
||||
{expanded ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
</span>
|
||||
)}
|
||||
<span>{node.name}</span>
|
||||
</div>
|
||||
|
||||
{node.isDirectory && expanded && node.children.length > 0 && (
|
||||
<TreeNodes nodes={node.children} level={level + 1} />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
414
src/components/preview_panel/PreviewIframe.tsx
Normal file
414
src/components/preview_panel/PreviewIframe.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { selectedAppIdAtom, appUrlAtom, appOutputAtom } from "@/atoms/appAtoms";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
RefreshCw,
|
||||
ExternalLink,
|
||||
Maximize2,
|
||||
Loader2,
|
||||
X,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Lightbulb,
|
||||
} from "lucide-react";
|
||||
import { chatInputValueAtom } from "@/atoms/chatAtoms";
|
||||
import { IpcClient } from "@/ipc/ipc_client";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { useLoadAppFile } from "@/hooks/useLoadAppFile";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: string | null;
|
||||
onDismiss: () => void;
|
||||
onAIFix: () => void;
|
||||
}
|
||||
|
||||
const ErrorBanner = ({ error, onDismiss, onAIFix }: ErrorBannerProps) => {
|
||||
if (!error) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute top-2 left-2 right-2 z-10 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md shadow-sm p-2">
|
||||
{/* Close button in top left */}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="absolute top-1 left-1 p-1 hover:bg-red-100 dark:hover:bg-red-900/50 rounded"
|
||||
>
|
||||
<X size={14} className="text-red-500 dark:text-red-400" />
|
||||
</button>
|
||||
|
||||
{/* Error message in the middle */}
|
||||
<div className="px-6 py-1 text-sm">
|
||||
<div className="text-red-700 dark:text-red-300 text-wrap">{error}</div>
|
||||
</div>
|
||||
|
||||
{/* Tip message */}
|
||||
<div className="mt-2 px-6">
|
||||
<div className="relative p-2 bg-red-100 dark:bg-red-900/50 rounded-sm flex gap-1 items-center">
|
||||
<div>
|
||||
<Lightbulb size={16} className=" text-red-800 dark:text-red-300" />
|
||||
</div>
|
||||
<span className="text-sm text-red-700 dark:text-red-400">
|
||||
<span className="font-medium">Tip: </span>Check if refreshing the
|
||||
page or restarting the app fixes the error.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Fix button at the bottom */}
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button
|
||||
onClick={onAIFix}
|
||||
className="cursor-pointer flex items-center space-x-1 px-2 py-0.5 bg-red-500 dark:bg-red-600 text-white rounded text-sm hover:bg-red-600 dark:hover:bg-red-700"
|
||||
>
|
||||
<Sparkles size={14} />
|
||||
<span>Fix error with AI</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Preview iframe component
|
||||
export const PreviewIframe = ({
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}) => {
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const { appUrl } = useAtomValue(appUrlAtom);
|
||||
const setAppOutput = useSetAtom(appOutputAtom);
|
||||
const { app } = useLoadApp(selectedAppId);
|
||||
|
||||
// State to trigger iframe reload
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const [iframeError, setIframeError] = useState<string | null>(null);
|
||||
const [showError, setShowError] = useState(true);
|
||||
const setInputValue = useSetAtom(chatInputValueAtom);
|
||||
const [availableRoutes, setAvailableRoutes] = useState<
|
||||
Array<{ path: string; label: string }>
|
||||
>([]);
|
||||
|
||||
// Load router related files to extract routes
|
||||
const { content: routerContent } = useLoadAppFile(
|
||||
selectedAppId,
|
||||
"src/App.tsx"
|
||||
);
|
||||
|
||||
// Effect to parse routes from the router file
|
||||
useEffect(() => {
|
||||
if (routerContent) {
|
||||
console.log("routerContent", routerContent);
|
||||
try {
|
||||
const routes: Array<{ path: string; label: string }> = [];
|
||||
|
||||
// Extract route imports and paths using regex for React Router syntax
|
||||
// Match <Route path="/path">
|
||||
const routePathsRegex = /<Route\s+(?:[^>]*\s+)?path=["']([^"']+)["']/g;
|
||||
let match;
|
||||
|
||||
// Find all route paths in the router content
|
||||
while ((match = routePathsRegex.exec(routerContent)) !== null) {
|
||||
const path = match[1];
|
||||
// Create a readable label from the path
|
||||
const label =
|
||||
path === "/"
|
||||
? "Home"
|
||||
: path
|
||||
.split("/")
|
||||
.filter((segment) => segment && !segment.startsWith(":"))
|
||||
.pop()
|
||||
?.replace(/[-_]/g, " ")
|
||||
.replace(/^\w/, (c) => c.toUpperCase()) || path;
|
||||
|
||||
if (!routes.some((r) => r.path === path)) {
|
||||
routes.push({ path, label });
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableRoutes(routes);
|
||||
} catch (e) {
|
||||
console.error("Error parsing router file:", e);
|
||||
}
|
||||
}
|
||||
}, [routerContent]);
|
||||
|
||||
// Navigation state
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([]);
|
||||
const [currentHistoryPosition, setCurrentHistoryPosition] = useState(0);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// Add message listener for iframe errors and navigation events
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
// Only handle messages from our iframe
|
||||
if (event.source !== iframeRef.current?.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, payload } = event.data;
|
||||
|
||||
if (type === "window-error") {
|
||||
const errorMessage = `Error in ${payload.filename} (line ${payload.lineno}, col ${payload.colno}): ${payload.message}`;
|
||||
console.error("Iframe error:", errorMessage);
|
||||
setIframeError(errorMessage);
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: `Iframe error: ${errorMessage}`,
|
||||
type: "client-error",
|
||||
appId: selectedAppId!,
|
||||
},
|
||||
]);
|
||||
} else if (type === "unhandled-rejection") {
|
||||
const errorMessage = `Unhandled Promise Rejection: ${payload.reason}`;
|
||||
console.error("Iframe unhandled rejection:", errorMessage);
|
||||
setIframeError(errorMessage);
|
||||
setAppOutput((prev) => [
|
||||
...prev,
|
||||
{
|
||||
message: `Iframe unhandled rejection: ${errorMessage}`,
|
||||
type: "client-error",
|
||||
appId: selectedAppId!,
|
||||
},
|
||||
]);
|
||||
} else if (type === "pushState" || type === "replaceState") {
|
||||
console.debug(`Navigation event: ${type}`, payload);
|
||||
|
||||
// Update navigation history based on the type of state change
|
||||
if (type === "pushState") {
|
||||
// For pushState, we trim any forward history and add the new URL
|
||||
const newHistory = [
|
||||
...navigationHistory.slice(0, currentHistoryPosition + 1),
|
||||
payload.newUrl,
|
||||
];
|
||||
setNavigationHistory(newHistory);
|
||||
setCurrentHistoryPosition(newHistory.length - 1);
|
||||
} else if (type === "replaceState") {
|
||||
// For replaceState, we replace the current URL
|
||||
const newHistory = [...navigationHistory];
|
||||
newHistory[currentHistoryPosition] = payload.newUrl;
|
||||
setNavigationHistory(newHistory);
|
||||
}
|
||||
|
||||
// Update navigation buttons state
|
||||
setCanGoBack(currentHistoryPosition > 0);
|
||||
setCanGoForward(currentHistoryPosition < navigationHistory.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}, [navigationHistory, currentHistoryPosition, selectedAppId]);
|
||||
|
||||
// Initialize navigation history when iframe loads
|
||||
useEffect(() => {
|
||||
if (appUrl) {
|
||||
setNavigationHistory([appUrl]);
|
||||
setCurrentHistoryPosition(0);
|
||||
setCanGoBack(false);
|
||||
setCanGoForward(false);
|
||||
}
|
||||
}, [appUrl]);
|
||||
|
||||
// Function to navigate back
|
||||
const handleNavigateBack = () => {
|
||||
if (canGoBack && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "navigate",
|
||||
payload: { direction: "backward" },
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
// Update our local state
|
||||
setCurrentHistoryPosition((prev) => prev - 1);
|
||||
setCanGoBack(currentHistoryPosition - 1 > 0);
|
||||
setCanGoForward(true);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to navigate forward
|
||||
const handleNavigateForward = () => {
|
||||
if (canGoForward && iframeRef.current?.contentWindow) {
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: "navigate",
|
||||
payload: { direction: "forward" },
|
||||
},
|
||||
"*"
|
||||
);
|
||||
|
||||
// Update our local state
|
||||
setCurrentHistoryPosition((prev) => prev + 1);
|
||||
setCanGoBack(true);
|
||||
setCanGoForward(
|
||||
currentHistoryPosition + 1 < navigationHistory.length - 1
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Function to handle reload
|
||||
const handleReload = () => {
|
||||
setReloadKey((prevKey) => prevKey + 1);
|
||||
// Optionally, add logic here if you need to explicitly stop/start the app again
|
||||
// For now, just changing the key should remount the iframe
|
||||
console.debug("Reloading iframe preview for app", selectedAppId);
|
||||
};
|
||||
|
||||
// Function to navigate to a specific route
|
||||
const navigateToRoute = (path: string) => {
|
||||
if (iframeRef.current?.contentWindow && appUrl) {
|
||||
// Create the full URL by combining the base URL with the path
|
||||
const baseUrl = new URL(appUrl).origin;
|
||||
const newUrl = `${baseUrl}${path}`;
|
||||
|
||||
// Navigate to the URL
|
||||
iframeRef.current.contentWindow.location.href = newUrl;
|
||||
|
||||
// Update navigation history
|
||||
const newHistory = [
|
||||
...navigationHistory.slice(0, currentHistoryPosition + 1),
|
||||
newUrl,
|
||||
];
|
||||
setNavigationHistory(newHistory);
|
||||
setCurrentHistoryPosition(newHistory.length - 1);
|
||||
setCanGoBack(true);
|
||||
setCanGoForward(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Display loading state
|
||||
if (loading) {
|
||||
return <div className="p-4 dark:text-gray-300">Loading app preview...</div>;
|
||||
}
|
||||
|
||||
// Display message if no app is selected
|
||||
if (selectedAppId === null) {
|
||||
return (
|
||||
<div className="p-4 text-gray-500 dark:text-gray-400">
|
||||
Select an app to see the preview.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Browser-style header */}
|
||||
<div className="flex items-center p-2 border-b space-x-2 ">
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoBack || loading || !selectedAppId}
|
||||
onClick={handleNavigateBack}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={!canGoForward || loading || !selectedAppId}
|
||||
onClick={handleNavigateForward}
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleReload}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
disabled={loading || !selectedAppId}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Address Bar with Routes Dropdown - using shadcn/ui dropdown-menu */}
|
||||
<div className="relative flex-grow">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div className="flex items-center justify-between px-3 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm text-gray-700 dark:text-gray-200 cursor-pointer w-full">
|
||||
<span>
|
||||
{navigationHistory[currentHistoryPosition]
|
||||
? new URL(navigationHistory[currentHistoryPosition])
|
||||
.pathname
|
||||
: "/"}
|
||||
</span>
|
||||
<ChevronDown size={14} />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-full">
|
||||
{availableRoutes.length > 0 ? (
|
||||
availableRoutes.map((route) => (
|
||||
<DropdownMenuItem
|
||||
key={route.path}
|
||||
onClick={() => navigateToRoute(route.path)}
|
||||
className="flex justify-between"
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xs">
|
||||
{route.path}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
) : (
|
||||
<DropdownMenuItem disabled>Loading routes...</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (appUrl) {
|
||||
IpcClient.getInstance().openExternalUrl(appUrl);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-gray-200 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed dark:text-gray-300"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative flex-grow ">
|
||||
<ErrorBanner
|
||||
error={showError ? error?.message || iframeError : null}
|
||||
onDismiss={() => setShowError(false)}
|
||||
onAIFix={() => {
|
||||
setInputValue(`Fix the error in ${error?.message || iframeError}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
{!appUrl ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center space-y-4 bg-gray-50 dark:bg-gray-950">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-400 dark:text-gray-500" />
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
Starting up your app...
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
key={reloadKey}
|
||||
title={`Preview for App ${selectedAppId}`}
|
||||
className="w-full h-full border-none bg-white dark:bg-gray-950"
|
||||
src={appUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
192
src/components/preview_panel/PreviewPanel.tsx
Normal file
192
src/components/preview_panel/PreviewPanel.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { previewModeAtom, selectedAppIdAtom } from "../../atoms/appAtoms";
|
||||
import { useLoadApp } from "@/hooks/useLoadApp";
|
||||
import { CodeView } from "./CodeView";
|
||||
import { PreviewIframe } from "./PreviewIframe";
|
||||
import {
|
||||
Eye,
|
||||
Code,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Logs,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
|
||||
import { Console } from "./Console";
|
||||
import { useRunApp } from "@/hooks/useRunApp";
|
||||
|
||||
type PreviewMode = "preview" | "code";
|
||||
|
||||
interface PreviewHeaderProps {
|
||||
previewMode: PreviewMode;
|
||||
setPreviewMode: (mode: PreviewMode) => void;
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
// Preview Header component with preview mode toggle
|
||||
const PreviewHeader = ({
|
||||
previewMode,
|
||||
setPreviewMode,
|
||||
onRestart,
|
||||
}: PreviewHeaderProps) => (
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
|
||||
<div className="relative flex space-x-2 bg-[var(--background-darkest)] rounded-md p-0.5">
|
||||
<button
|
||||
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
|
||||
onClick={() => setPreviewMode("preview")}
|
||||
>
|
||||
{previewMode === "preview" && (
|
||||
<motion.div
|
||||
layoutId="activeIndicator"
|
||||
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
<Eye size={16} />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
<button
|
||||
className="relative flex items-center space-x-1 px-3 py-1 rounded-md text-sm z-10"
|
||||
onClick={() => setPreviewMode("code")}
|
||||
>
|
||||
{previewMode === "code" && (
|
||||
<motion.div
|
||||
layoutId="activeIndicator"
|
||||
className="absolute inset-0 bg-(--background-lightest) shadow rounded-md -z-1"
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
<Code size={16} />
|
||||
<span>Code</span>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onRestart}
|
||||
className="flex items-center space-x-1 px-3 py-1 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
|
||||
title="Restart App"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<span>Restart</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Console header component
|
||||
const ConsoleHeader = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) => (
|
||||
<div
|
||||
onClick={onToggle}
|
||||
className="flex items-center gap-2 px-4 py-1.5 border-t border-border cursor-pointer hover:bg-[var(--background-darkest)] transition-colors"
|
||||
>
|
||||
<Logs size={16} />
|
||||
<span className="text-sm font-medium">System Messages</span>
|
||||
<div className="flex-1" />
|
||||
{isOpen ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
// Main PreviewPanel component
|
||||
export function PreviewPanel() {
|
||||
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
|
||||
const selectedAppId = useAtomValue(selectedAppIdAtom);
|
||||
const [isConsoleOpen, setIsConsoleOpen] = useState(false);
|
||||
const { runApp, stopApp, restartApp, error, loading, app } = useRunApp();
|
||||
const runningAppIdRef = useRef<number | null>(null);
|
||||
|
||||
const handleRestart = useCallback(() => {
|
||||
if (selectedAppId !== null) {
|
||||
restartApp(selectedAppId);
|
||||
}
|
||||
}, [selectedAppId, restartApp]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousAppId = runningAppIdRef.current;
|
||||
|
||||
// Check if the selected app ID has changed
|
||||
if (selectedAppId !== previousAppId) {
|
||||
// Stop the previously running app, if any
|
||||
if (previousAppId !== null) {
|
||||
console.debug("Stopping previous app", previousAppId);
|
||||
stopApp(previousAppId);
|
||||
// We don't necessarily nullify the ref here immediately,
|
||||
// let the start of the next app update it or unmount handle it.
|
||||
}
|
||||
|
||||
// Start the new app if an ID is selected
|
||||
if (selectedAppId !== null) {
|
||||
console.debug("Starting new app", selectedAppId);
|
||||
runApp(selectedAppId); // Consider adding error handling for the promise if needed
|
||||
runningAppIdRef.current = selectedAppId; // Update ref to the new running app ID
|
||||
} else {
|
||||
// If selectedAppId is null, ensure no app is marked as running
|
||||
runningAppIdRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function: This runs when the component unmounts OR before the effect runs again.
|
||||
// We only want to stop the app on actual unmount. The logic above handles stopping
|
||||
// when the appId changes. So, we capture the running appId at the time the effect renders.
|
||||
const appToStopOnUnmount = runningAppIdRef.current;
|
||||
return () => {
|
||||
if (appToStopOnUnmount !== null) {
|
||||
const currentRunningApp = runningAppIdRef.current;
|
||||
if (currentRunningApp !== null) {
|
||||
console.debug(
|
||||
"Component unmounting or selectedAppId changing, stopping app",
|
||||
currentRunningApp
|
||||
);
|
||||
stopApp(currentRunningApp);
|
||||
runningAppIdRef.current = null; // Clear ref on stop
|
||||
}
|
||||
}
|
||||
};
|
||||
// Dependencies: run effect when selectedAppId changes.
|
||||
// runApp/stopApp are stable due to useCallback.
|
||||
}, [selectedAppId, runApp, stopApp]);
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PreviewHeader
|
||||
previewMode={previewMode}
|
||||
setPreviewMode={setPreviewMode}
|
||||
onRestart={handleRestart}
|
||||
/>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PanelGroup direction="vertical">
|
||||
<Panel id="content" minSize={30}>
|
||||
<div className="h-full overflow-y-auto">
|
||||
{previewMode === "preview" ? (
|
||||
<PreviewIframe loading={loading} error={error} />
|
||||
) : (
|
||||
<CodeView loading={loading} error={error} app={app} />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
{isConsoleOpen && (
|
||||
<>
|
||||
<PanelResizeHandle className="h-1 bg-border hover:bg-gray-400 transition-colors cursor-row-resize" />
|
||||
<Panel id="console" minSize={10} defaultSize={30}>
|
||||
<div className="flex flex-col h-full">
|
||||
<ConsoleHeader
|
||||
isOpen={true}
|
||||
onToggle={() => setIsConsoleOpen(false)}
|
||||
/>
|
||||
<Console />
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</PanelGroup>
|
||||
</div>
|
||||
{!isConsoleOpen && (
|
||||
<ConsoleHeader isOpen={false} onToggle={() => setIsConsoleOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user