Extract panel header to title bar (#625)

This commit is contained in:
Will Chen
2025-07-10 16:37:05 -07:00
committed by GitHub
parent 5f3dea6180
commit 4b84b12fe3
6 changed files with 253 additions and 237 deletions

View File

@@ -1,7 +1,7 @@
import { useAtom } from "jotai";
import { selectedAppIdAtom } from "@/atoms/appAtoms";
import { useLoadApps } from "@/hooks/useLoadApps";
import { useRouter } from "@tanstack/react-router";
import { useRouter, useLocation } from "@tanstack/react-router";
import { useSettings } from "@/hooks/useSettings";
import { Button } from "@/components/ui/button";
// @ts-ignore
@@ -20,11 +20,13 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { PreviewHeader } from "@/components/preview_panel/PreviewHeader";
export const TitleBar = () => {
const [selectedAppId] = useAtom(selectedAppIdAtom);
const { apps } = useLoadApps();
const { navigate } = useRouter();
const location = useLocation();
const { settings, refreshSettings } = useSettings();
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
const [showWindowControls, setShowWindowControls] = useState(false);
@@ -90,6 +92,14 @@ export const TitleBar = () => {
{displayText}
</Button>
{isDyadPro && <DyadProButton isDyadProEnabled={isDyadProEnabled} />}
{/* Preview Header */}
{location.pathname === "/chat" && (
<div className="flex-1 flex justify-end no-app-region-drag">
<PreviewHeader />
</div>
)}
{showWindowControls && <WindowsControls />}
</div>

View File

@@ -0,0 +1,214 @@
import { useAtom, useAtomValue } from "jotai";
import { previewModeAtom, selectedAppIdAtom } from "../../atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import {
Eye,
Code,
MoreVertical,
Cog,
Trash2,
AlertTriangle,
} from "lucide-react";
import { motion } from "framer-motion";
import { useEffect, useRef, useState, useCallback } from "react";
import { useRunApp } from "@/hooks/useRunApp";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { showError, showSuccess } from "@/lib/toast";
import { useMutation } from "@tanstack/react-query";
import { useCheckProblems } from "@/hooks/useCheckProblems";
import { isPreviewOpenAtom } from "@/atoms/viewAtoms";
export type PreviewMode = "preview" | "code" | "problems";
// Preview Header component with preview mode toggle
export const PreviewHeader = () => {
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
const [isPreviewOpen, setIsPreviewOpen] = useAtom(isPreviewOpenAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const previewRef = useRef<HTMLButtonElement>(null);
const codeRef = useRef<HTMLButtonElement>(null);
const problemsRef = useRef<HTMLButtonElement>(null);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
const { problemReport } = useCheckProblems(selectedAppId);
const { restartApp, refreshAppIframe } = useRunApp();
const selectPanel = (panel: PreviewMode) => {
if (previewMode === panel) {
setIsPreviewOpen(!isPreviewOpen);
} else {
setPreviewMode(panel);
setIsPreviewOpen(true);
}
};
const onCleanRestart = useCallback(() => {
restartApp({ removeNodeModules: true });
}, [restartApp]);
const useClearSessionData = () => {
return useMutation({
mutationFn: () => {
const ipcClient = IpcClient.getInstance();
return ipcClient.clearSessionData();
},
onSuccess: async () => {
await refreshAppIframe();
showSuccess("Preview data cleared");
},
onError: (error) => {
showError(`Error clearing preview data: ${error}`);
},
});
};
const { mutate: clearSessionData } = useClearSessionData();
const onClearSessionData = useCallback(() => {
clearSessionData();
}, [clearSessionData]);
// Get the problem count for the selected app
const problemCount = problemReport ? problemReport.problems.length : 0;
// Format the problem count for display
const formatProblemCount = (count: number): string => {
if (count === 0) return "";
if (count > 100) return "100+";
return count.toString();
};
const displayCount = formatProblemCount(problemCount);
// Update indicator position when mode changes
useEffect(() => {
const updateIndicator = () => {
let targetRef: React.RefObject<HTMLButtonElement | null>;
switch (previewMode) {
case "preview":
targetRef = previewRef;
break;
case "code":
targetRef = codeRef;
break;
case "problems":
targetRef = problemsRef;
break;
default:
return;
}
if (targetRef.current) {
const button = targetRef.current;
const container = button.parentElement;
if (container) {
const containerRect = container.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const left = buttonRect.left - containerRect.left;
const width = buttonRect.width;
setIndicatorStyle({ left, width });
if (!isPreviewOpen) {
setIndicatorStyle({ left: left, width: 0 });
}
}
}
};
// Small delay to ensure DOM is updated
const timeoutId = setTimeout(updateIndicator, 10);
return () => clearTimeout(timeoutId);
}, [previewMode, displayCount, isPreviewOpen]);
return (
<div className="flex items-center justify-between px-4 py-2 mt-1 border-b border-border">
<div className="relative flex rounded-md p-0.5 gap-2">
<motion.div
className="absolute top-0.5 bottom-0.5 bg-[var(--background-lightest)] shadow rounded-md"
animate={{
left: indicatorStyle.left,
width: indicatorStyle.width,
}}
transition={{
type: "spring",
stiffness: 600,
damping: 35,
mass: 0.6,
}}
/>
<button
data-testid="preview-mode-button"
ref={previewRef}
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => selectPanel("preview")}
>
<Eye size={14} />
<span>Preview</span>
</button>
<button
data-testid="problems-mode-button"
ref={problemsRef}
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => selectPanel("problems")}
>
<AlertTriangle size={14} />
<span>Problems</span>
{displayCount && (
<span className="ml-0.5 px-1 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-full min-w-[16px] text-center">
{displayCount}
</span>
)}
</button>
<button
data-testid="code-mode-button"
ref={codeRef}
className="cursor-pointer relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => selectPanel("code")}
>
<Code size={14} />
<span>Code</span>
</button>
</div>
<div className="flex items-center">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
data-testid="preview-more-options-button"
className="flex items-center justify-center p-1.5 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
title="More options"
>
<MoreVertical size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem onClick={onCleanRestart}>
<Cog size={16} />
<div className="flex flex-col">
<span>Rebuild</span>
<span className="text-xs text-muted-foreground">
Re-installs node_modules and restarts
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={onClearSessionData}>
<Trash2 size={16} />
<div className="flex flex-col">
<span>Clear Cache</span>
<span className="text-xs text-muted-foreground">
Clears cookies and local storage and other app cache
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};

View File

@@ -18,6 +18,7 @@ import {
Lightbulb,
ChevronRight,
MousePointerClick,
Power,
} from "lucide-react";
import { selectedChatIdAtom } from "@/atoms/chatAtoms";
import { IpcClient } from "@/ipc/ipc_client";
@@ -38,6 +39,7 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useRunApp } from "@/hooks/useRunApp";
interface ErrorBannerProps {
error: string | undefined;
@@ -129,7 +131,7 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
const [availableRoutes, setAvailableRoutes] = useState<
Array<{ path: string; label: string }>
>([]);
const { restartApp } = useRunApp();
// Load router related files to extract routes
const { content: routerContent } = useLoadAppFile(
selectedAppId,
@@ -423,6 +425,10 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
);
}
const onRestart = () => {
restartApp();
};
return (
<div className="flex flex-col h-full">
{/* Browser-style header */}
@@ -519,6 +525,14 @@ export const PreviewIframe = ({ loading }: { loading: boolean }) => {
{/* Action Buttons */}
<div className="flex space-x-1">
<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"
>
<Power size={16} />
<span>Restart</span>
</button>
<button
data-testid="preview-open-browser-button"
onClick={() => {

View File

@@ -5,47 +5,15 @@ import {
previewPanelKeyAtom,
selectedAppIdAtom,
} from "../../atoms/appAtoms";
import { IpcClient } from "@/ipc/ipc_client";
import { CodeView } from "./CodeView";
import { PreviewIframe } from "./PreviewIframe";
import { Problems } from "./Problems";
import {
Eye,
Code,
ChevronDown,
ChevronUp,
Logs,
MoreVertical,
Cog,
Power,
Trash2,
AlertTriangle,
} from "lucide-react";
import { motion } from "framer-motion";
import { useEffect, useRef, useState, useCallback } from "react";
import { ChevronDown, ChevronUp, Logs } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { PanelGroup, Panel, PanelResizeHandle } from "react-resizable-panels";
import { Console } from "./Console";
import { useRunApp } from "@/hooks/useRunApp";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { showError, showSuccess } from "@/lib/toast";
import { useMutation } from "@tanstack/react-query";
import { useCheckProblems } from "@/hooks/useCheckProblems";
type PreviewMode = "preview" | "code" | "problems";
interface PreviewHeaderProps {
previewMode: PreviewMode;
setPreviewMode: (mode: PreviewMode) => void;
onRestart: () => void;
onCleanRestart: () => void;
onClearSessionData: () => void;
}
interface ConsoleHeaderProps {
isOpen: boolean;
@@ -53,164 +21,6 @@ interface ConsoleHeaderProps {
latestMessage?: string;
}
// Preview Header component with preview mode toggle
const PreviewHeader = ({
previewMode,
setPreviewMode,
onRestart,
onCleanRestart,
onClearSessionData,
}: PreviewHeaderProps) => {
const selectedAppId = useAtomValue(selectedAppIdAtom);
const previewRef = useRef<HTMLButtonElement>(null);
const codeRef = useRef<HTMLButtonElement>(null);
const problemsRef = useRef<HTMLButtonElement>(null);
const [indicatorStyle, setIndicatorStyle] = useState({ left: 0, width: 0 });
const { problemReport } = useCheckProblems(selectedAppId);
// Get the problem count for the selected app
const problemCount = problemReport ? problemReport.problems.length : 0;
// Format the problem count for display
const formatProblemCount = (count: number): string => {
if (count === 0) return "";
if (count > 100) return "100+";
return count.toString();
};
const displayCount = formatProblemCount(problemCount);
// Update indicator position when mode changes
useEffect(() => {
const updateIndicator = () => {
let targetRef: React.RefObject<HTMLButtonElement | null>;
switch (previewMode) {
case "preview":
targetRef = previewRef;
break;
case "code":
targetRef = codeRef;
break;
case "problems":
targetRef = problemsRef;
break;
default:
return;
}
if (targetRef.current) {
const button = targetRef.current;
const container = button.parentElement;
if (container) {
const containerRect = container.getBoundingClientRect();
const buttonRect = button.getBoundingClientRect();
const left = buttonRect.left - containerRect.left;
const width = buttonRect.width;
setIndicatorStyle({ left, width });
}
}
};
// Small delay to ensure DOM is updated
const timeoutId = setTimeout(updateIndicator, 10);
return () => clearTimeout(timeoutId);
}, [previewMode, displayCount]);
return (
<div className="flex items-center justify-between px-4 py-2 border-b border-border">
<div className="relative flex bg-[var(--background-darkest)] rounded-md p-0.5">
<motion.div
className="absolute top-0.5 bottom-0.5 bg-[var(--background-lightest)] shadow rounded-md"
animate={{
left: indicatorStyle.left,
width: indicatorStyle.width,
}}
transition={{
type: "spring",
stiffness: 600,
damping: 35,
mass: 0.6,
}}
/>
<button
data-testid="preview-mode-button"
ref={previewRef}
className="relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => setPreviewMode("preview")}
>
<Eye size={14} />
<span>Preview</span>
</button>
<button
data-testid="problems-mode-button"
ref={problemsRef}
className="relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => setPreviewMode("problems")}
>
<AlertTriangle size={14} />
<span>Problems</span>
{displayCount && (
<span className="ml-0.5 px-1 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-full min-w-[16px] text-center">
{displayCount}
</span>
)}
</button>
<button
data-testid="code-mode-button"
ref={codeRef}
className="relative flex items-center gap-1 px-2 py-1 rounded-md text-sm font-medium z-10"
onClick={() => setPreviewMode("code")}
>
<Code size={14} />
<span>Code</span>
</button>
</div>
<div className="flex items-center">
<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"
>
<Power size={16} />
<span>Restart</span>
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
data-testid="preview-more-options-button"
className="flex items-center justify-center p-1.5 rounded-md text-sm hover:bg-[var(--background-darkest)] transition-colors"
title="More options"
>
<MoreVertical size={16} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem onClick={onCleanRestart}>
<Cog size={16} />
<div className="flex flex-col">
<span>Rebuild</span>
<span className="text-xs text-muted-foreground">
Re-installs node_modules and restarts
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={onClearSessionData}>
<Trash2 size={16} />
<div className="flex flex-col">
<span>Clear Cache</span>
<span className="text-xs text-muted-foreground">
Clears cookies and local storage and other app cache
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
};
// Console header component
const ConsoleHeader = ({
isOpen,
@@ -237,11 +47,10 @@ const ConsoleHeader = ({
// Main PreviewPanel component
export function PreviewPanel() {
const [previewMode, setPreviewMode] = useAtom(previewModeAtom);
const [previewMode] = useAtom(previewModeAtom);
const selectedAppId = useAtomValue(selectedAppIdAtom);
const [isConsoleOpen, setIsConsoleOpen] = useState(false);
const { runApp, stopApp, restartApp, loading, app, refreshAppIframe } =
useRunApp();
const { runApp, stopApp, loading, app } = useRunApp();
const runningAppIdRef = useRef<number | null>(null);
const key = useAtomValue(previewPanelKeyAtom);
const appOutput = useAtomValue(appOutputAtom);
@@ -250,37 +59,6 @@ export function PreviewPanel() {
const latestMessage =
messageCount > 0 ? appOutput[messageCount - 1]?.message : undefined;
const handleRestart = useCallback(() => {
restartApp();
}, [restartApp]);
const handleCleanRestart = useCallback(() => {
restartApp({ removeNodeModules: true });
}, [restartApp]);
const useClearSessionData = () => {
return useMutation({
mutationFn: () => {
const ipcClient = IpcClient.getInstance();
return ipcClient.clearSessionData();
},
onSuccess: async () => {
await refreshAppIframe();
showSuccess("Preview data cleared");
// Optionally invalidate relevant queries
},
onError: (error) => {
showError(`Error clearing preview data: ${error}`);
},
});
};
const { mutate: clearSessionData } = useClearSessionData();
const handleClearSessionData = useCallback(() => {
clearSessionData();
}, [selectedAppId, clearSessionData]);
useEffect(() => {
const previousAppId = runningAppIdRef.current;
@@ -327,13 +105,6 @@ export function PreviewPanel() {
}, [selectedAppId, runApp, stopApp]);
return (
<div className="flex flex-col h-full">
<PreviewHeader
previewMode={previewMode}
setPreviewMode={setPreviewMode}
onRestart={handleRestart}
onCleanRestart={handleCleanRestart}
onClearSessionData={handleClearSessionData}
/>
<div className="flex-1 overflow-hidden">
<PanelGroup direction="vertical">
<Panel id="content" minSize={30}>

View File

@@ -1,4 +1,5 @@
import { useState, useCallback } from "react";
import { useCallback } from "react";
import { atom } from "jotai";
import { IpcClient } from "@/ipc/ipc_client";
import {
appOutputAtom,
@@ -11,8 +12,10 @@ import {
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { AppOutput } from "@/ipc/ipc_types";
const useRunAppLoadingAtom = atom(false);
export function useRunApp() {
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useAtom(useRunAppLoadingAtom);
const [app, setApp] = useAtom(currentAppAtom);
const setAppOutput = useSetAtom(appOutputAtom);
const [appUrlObj, setAppUrlObj] = useAtom(appUrlAtom);