mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
fix: add missing LocalhostViewer and SpecsViewer components
- Add LocalhostViewer.tsx for iframe-based localhost viewing with navigation controls - Add SpecsViewer.tsx for viewing markdown spec files with file browser - Fixes import errors in SidecarTabs.tsx that were preventing build These components are required dependencies for the SidecarTabs component to function properly in the multi-container sidecar system.
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { RefreshCw, ExternalLink, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/Tooltip';
|
||||
|
||||
interface LocalhostViewerProps {
|
||||
initialUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
}
|
||||
|
||||
function isValidLocalhostUrl(url: string): boolean {
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return (
|
||||
parsedUrl.protocol === 'http:' &&
|
||||
(parsedUrl.hostname === 'localhost' || parsedUrl.hostname === '127.0.0.1')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUrl(input: string): string {
|
||||
// If it's just a port number, prepend localhost
|
||||
if (/^\d+$/.test(input.trim())) {
|
||||
return `http://localhost:${input.trim()}`;
|
||||
}
|
||||
|
||||
// If it starts with localhost: or 127.0.0.1: without protocol
|
||||
if (/^(localhost|127\.0\.0\.1):\d+/.test(input.trim())) {
|
||||
return `http://${input.trim()}`;
|
||||
}
|
||||
|
||||
return input.trim();
|
||||
}
|
||||
|
||||
export function LocalhostViewer({
|
||||
initialUrl = 'http://localhost:3000',
|
||||
onUrlChange,
|
||||
}: LocalhostViewerProps) {
|
||||
// Initialize from localStorage or use initialUrl
|
||||
const [url, setUrl] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('goose-sidecar-url') || initialUrl;
|
||||
}
|
||||
return initialUrl;
|
||||
});
|
||||
|
||||
const [inputUrl, setInputUrl] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return localStorage.getItem('goose-sidecar-url') || initialUrl;
|
||||
}
|
||||
return initialUrl;
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [canGoBack, setCanGoBack] = useState(false);
|
||||
const [canGoForward, setCanGoForward] = useState(false);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [iframeReady, setIframeReady] = useState(false);
|
||||
// eslint-disable-next-line no-undef
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (onUrlChange) {
|
||||
onUrlChange(url);
|
||||
}
|
||||
}, [url, onUrlChange]);
|
||||
|
||||
// Poll the server until it's ready before showing iframe
|
||||
useEffect(() => {
|
||||
if (iframeReady) return; // Don't run if already ready
|
||||
|
||||
setIsLoading(true);
|
||||
let mounted = true;
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let attemptCount = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
const checkServerReady = async () => {
|
||||
attemptCount++;
|
||||
|
||||
try {
|
||||
// Try to fetch from the URL to see if server is responding
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 2000); // 2 second timeout
|
||||
|
||||
await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
mode: 'no-cors', // Use no-cors to avoid CORS issues during check
|
||||
});
|
||||
|
||||
window.clearTimeout(timeoutId);
|
||||
|
||||
// If we get here without throwing, server is likely ready
|
||||
// Note: with mode: 'no-cors', we can't read the response but no error means connection succeeded
|
||||
console.log(`Server at ${url} is ready`);
|
||||
|
||||
if (mounted) {
|
||||
setIframeReady(true);
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Server not ready yet (attempt ${attemptCount}/${maxAttempts}):`, error);
|
||||
|
||||
if (attemptCount >= maxAttempts) {
|
||||
// Give up and show the iframe anyway - let the user manually refresh if needed
|
||||
console.log('Max attempts reached, showing iframe anyway');
|
||||
if (mounted) {
|
||||
setIframeReady(true);
|
||||
if (pollInterval) {
|
||||
clearInterval(pollInterval);
|
||||
pollInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initial delay to let server start
|
||||
const initialTimer = setTimeout(() => {
|
||||
if (!mounted) return;
|
||||
|
||||
// First check
|
||||
checkServerReady();
|
||||
|
||||
// Set up polling
|
||||
pollInterval = setInterval(() => {
|
||||
if (mounted && !iframeReady) {
|
||||
checkServerReady();
|
||||
}
|
||||
}, 1000); // Poll every second
|
||||
}, 800); // Wait 800ms before first check
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
window.clearTimeout(initialTimer);
|
||||
if (pollInterval) {
|
||||
window.clearInterval(pollInterval);
|
||||
}
|
||||
};
|
||||
}, [url, iframeReady]); // Re-run when URL changes or iframeReady resets
|
||||
|
||||
const handleUrlSubmit = (newUrl: string) => {
|
||||
const formattedUrl = formatUrl(newUrl);
|
||||
|
||||
if (!isValidLocalhostUrl(formattedUrl)) {
|
||||
setError('Only localhost URLs are allowed (e.g., http://localhost:3000)');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setUrl(formattedUrl);
|
||||
setInputUrl(formattedUrl);
|
||||
setIsLoading(true);
|
||||
setRetryCount(0); // Reset retry count when changing URL
|
||||
setIframeReady(false); // Reset iframe ready state to trigger re-check
|
||||
|
||||
// Save to localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('goose-sidecar-url', formattedUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleUrlSubmit(inputUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (iframeRef.current) {
|
||||
setIsLoading(true);
|
||||
// Create a new URL to force refresh
|
||||
const currentSrc = iframeRef.current.src;
|
||||
iframeRef.current.src = '';
|
||||
iframeRef.current.src = currentSrc;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInBrowser = () => {
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
try {
|
||||
iframeRef.current.contentWindow.history.back();
|
||||
} catch (e) {
|
||||
console.warn('Cannot access iframe history:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoForward = () => {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
try {
|
||||
iframeRef.current.contentWindow.history.forward();
|
||||
} catch (e) {
|
||||
console.warn('Cannot access iframe history:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
setRetryCount(0); // Reset retry count on successful load
|
||||
|
||||
// Clear any pending retry timeout
|
||||
if (retryTimeoutRef.current) {
|
||||
window.clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Try to update navigation state (may not work due to CORS)
|
||||
try {
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
setCanGoBack(iframeRef.current.contentWindow.history.length > 1);
|
||||
setCanGoForward(false); // Reset forward state
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore CORS errors when trying to access iframe history
|
||||
}
|
||||
};
|
||||
|
||||
const handleIframeError = () => {
|
||||
// Implement retry logic with exponential backoff
|
||||
const maxRetries = 3;
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
const retryDelay = Math.min(1000 * Math.pow(2, retryCount), 5000); // Exponential backoff: 1s, 2s, 4s (max 5s)
|
||||
|
||||
console.log(
|
||||
`Retrying to load ${url} (attempt ${retryCount + 1}/${maxRetries}) in ${retryDelay}ms...`
|
||||
);
|
||||
|
||||
// Clear any existing retry timeout
|
||||
if (retryTimeoutRef.current) {
|
||||
window.clearTimeout(retryTimeoutRef.current);
|
||||
}
|
||||
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
setRetryCount((prev) => prev + 1);
|
||||
if (iframeRef.current) {
|
||||
// Force reload by setting src
|
||||
const currentSrc = iframeRef.current.src;
|
||||
iframeRef.current.src = '';
|
||||
iframeRef.current.src = currentSrc;
|
||||
}
|
||||
}, retryDelay);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
setError(
|
||||
`Failed to load ${url} after ${maxRetries} attempts. Make sure the server is running.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Clean up retry timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (retryTimeoutRef.current) {
|
||||
window.clearTimeout(retryTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-background-default">
|
||||
{/* URL Bar and Controls */}
|
||||
<div className="flex items-center gap-2 p-3 border-b border-borderSubtle bg-background-muted">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoBack}
|
||||
disabled={!canGoBack}
|
||||
className="p-1 h-8 w-8"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go Back</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleGoForward}
|
||||
disabled={!canGoForward}
|
||||
className="p-1 h-8 w-8"
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Go Forward</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={handleRefresh} className="p-1 h-8 w-8">
|
||||
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* URL Input */}
|
||||
<div className="flex-1 flex items-center">
|
||||
<input
|
||||
type="text"
|
||||
value={inputUrl}
|
||||
onChange={(e) => setInputUrl(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="http://localhost:3000"
|
||||
className="flex-1 px-3 py-1 text-sm border border-borderSubtle rounded-md bg-background-default text-textStandard focus:outline-none focus:ring-2 focus:ring-borderProminent focus:border-transparent"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleUrlSubmit(inputUrl)}
|
||||
className="ml-2 px-3 py-1 text-xs"
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* External link button */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenInBrowser} className="p-1 h-8 w-8">
|
||||
<ExternalLink size={14} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open in Browser</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
|
||||
<p className="text-red-800 dark:text-red-200 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Iframe Content */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
{iframeReady && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={url}
|
||||
className="w-full h-full border-0"
|
||||
title="Localhost Viewer"
|
||||
onLoad={handleIframeLoad}
|
||||
onError={handleIframeError}
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-top-navigation-by-user-activation"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Loading overlay - show while waiting for iframe to be ready or while loading */}
|
||||
{(!iframeReady || isLoading) && (
|
||||
<div className="absolute inset-0 bg-background-default/80 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2 text-primary" />
|
||||
<p className="text-textSubtle text-sm">
|
||||
{!iframeReady ? 'Initializing...' : `Loading ${url}...`}
|
||||
{retryCount > 0 && ` (retry ${retryCount}/3)`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocalhostViewer;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { FileText, ChevronRight, AlertCircle } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface SpecFile {
|
||||
name: string;
|
||||
path: string;
|
||||
lastModified: number;
|
||||
}
|
||||
|
||||
interface SpecsViewerProps {
|
||||
projectPath: string;
|
||||
}
|
||||
|
||||
export function SpecsViewer({ projectPath }: SpecsViewerProps) {
|
||||
const [specFiles, setSpecFiles] = useState<SpecFile[]>([]);
|
||||
const [selectedFile, setSelectedFile] = useState<SpecFile | null>(null);
|
||||
const [fileContent, setFileContent] = useState<string>('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use a ref to always have access to the current selected file
|
||||
const selectedFileRef = useRef<SpecFile | null>(null);
|
||||
selectedFileRef.current = selectedFile;
|
||||
|
||||
const loadFileContent = useCallback(async (filePath: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
const content = await window.electron.readSpecFile(filePath);
|
||||
|
||||
setFileContent((prevContent) => {
|
||||
if (prevContent !== content) {
|
||||
return content;
|
||||
}
|
||||
return prevContent;
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
setError('Failed to read spec file');
|
||||
setFileContent('');
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSpecFiles = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const files = await window.electron.listSpecFiles(projectPath);
|
||||
setSpecFiles(files);
|
||||
|
||||
// Update selected file if it still exists
|
||||
setSelectedFile((prevSelected) => {
|
||||
if (prevSelected) {
|
||||
const stillExists = files.find((f) => f.path === prevSelected.path);
|
||||
if (stillExists) {
|
||||
return stillExists;
|
||||
}
|
||||
// File was deleted, select first available
|
||||
return files.length > 0 ? files[0] : null;
|
||||
} else if (files.length > 0) {
|
||||
// Auto-select first file if none selected
|
||||
return files[0];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return files;
|
||||
} catch (err) {
|
||||
setError('Failed to load spec files');
|
||||
return [];
|
||||
}
|
||||
}, [projectPath]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSpecFiles();
|
||||
window.electron.watchSpecDirectory(projectPath);
|
||||
|
||||
return () => {
|
||||
window.electron.unwatchSpecDirectory(projectPath);
|
||||
};
|
||||
}, [projectPath, loadSpecFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFileChanges = (data: {
|
||||
eventType: string;
|
||||
filename: string;
|
||||
directory: string;
|
||||
}) => {
|
||||
loadSpecFiles();
|
||||
|
||||
const currentSelected = selectedFileRef.current;
|
||||
if (currentSelected) {
|
||||
const currentFileName = currentSelected.path.split('/').pop() || '';
|
||||
|
||||
if (currentFileName === data.filename) {
|
||||
setTimeout(() => {
|
||||
loadFileContent(currentSelected.path);
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.electron.onSpecFilesChanged(handleFileChanges);
|
||||
|
||||
return () => {
|
||||
window.electron.offSpecFilesChanged(handleFileChanges);
|
||||
};
|
||||
}, [loadSpecFiles, loadFileContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedFile) {
|
||||
loadFileContent(selectedFile.path);
|
||||
}
|
||||
}, [selectedFile, loadFileContent]);
|
||||
|
||||
const formatFileName = (name: string) => {
|
||||
return name.replace(/\.md$/i, '').replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
if (specFiles.length === 0) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-background-default">
|
||||
<div className="text-center p-8">
|
||||
<FileText className="w-12 h-12 text-textMuted mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-textStandard mb-2">No Specs Found</h3>
|
||||
<p className="text-textMuted text-sm max-w-md">
|
||||
No markdown files found in the specs/ folder of this project. Add .md files to the
|
||||
specs/ folder to view them here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex bg-background-default">
|
||||
{/* Sidebar with file list */}
|
||||
<div className="w-64 border-r border-borderSubtle bg-background-muted flex flex-col">
|
||||
<div className="p-3 border-b border-borderSubtle">
|
||||
<h3 className="text-sm font-medium text-textStandard">Spec Files</h3>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{specFiles.map((file) => (
|
||||
<button
|
||||
key={file.path}
|
||||
onClick={() => setSelectedFile(file)}
|
||||
className={`w-full px-3 py-2 flex items-center gap-2 text-left text-sm transition-colors ${
|
||||
selectedFile?.path === file.path
|
||||
? 'bg-background-default text-textStandard border-l-2 border-primary'
|
||||
: 'text-textMuted hover:bg-background-default/50 hover:text-textStandard'
|
||||
}`}
|
||||
>
|
||||
<FileText size={14} className="flex-shrink-0" />
|
||||
<span className="truncate">{formatFileName(file.name)}</span>
|
||||
{selectedFile?.path === file.path && (
|
||||
<ChevronRight size={14} className="ml-auto flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{error ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center p-8">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<p className="text-textMuted">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
|
||||
<p className="text-textMuted text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedFile ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-semibold text-textStandard mb-4">
|
||||
{formatFileName(selectedFile.name)}
|
||||
</h2>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{fileContent}</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<p className="text-textMuted">Select a file to view</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpecsViewer;
|
||||
Reference in New Issue
Block a user