feat: improve preview url loading (#78)

This commit is contained in:
Carlos Florêncio
2026-01-22 22:20:10 +00:00
committed by GitHub
parent 8525cc0d87
commit d0030e728e
5 changed files with 725 additions and 367 deletions
-113
View File
@@ -1,113 +0,0 @@
'use client';
import { ReactNode, useCallback, useRef, useState } from 'react';
import { cn } from '@/lib/utils';
type Direction = 'horizontal' | 'vertical';
interface ResizableSplitProps {
direction: Direction;
initialSizes?: [number, number];
minSizes?: [number, number];
className?: string;
handleClassName?: string;
children: [ReactNode, ReactNode];
}
export function ResizableSplit({
direction,
initialSizes = [50, 50],
minSizes = [20, 20],
className,
handleClassName,
children,
}: ResizableSplitProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [sizes, setSizes] = useState<[number, number]>(initialSizes);
const [isDragging, setIsDragging] = useState(false);
const clampSizes = useCallback(
(nextFirst: number) => {
const minFirst = minSizes[0];
const minSecond = minSizes[1];
const clampedFirst = Math.min(100 - minSecond, Math.max(minFirst, nextFirst));
return [clampedFirst, 100 - clampedFirst] as [number, number];
},
[minSizes]
);
const updateSizes = useCallback(
(event: PointerEvent | React.PointerEvent) => {
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const total = direction === 'horizontal' ? rect.width : rect.height;
if (!total) return;
const offset =
direction === 'horizontal' ? event.clientX - rect.left : event.clientY - rect.top;
const nextFirst = (offset / total) * 100;
setSizes(clampSizes(nextFirst));
},
[clampSizes, direction]
);
const handlePointerDown = useCallback(
(event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragging(true);
updateSizes(event);
const handleMove = (moveEvent: PointerEvent) => {
updateSizes(moveEvent);
};
const handleUp = (upEvent: PointerEvent) => {
updateSizes(upEvent);
setIsDragging(false);
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
},
[updateSizes]
);
const isHorizontal = direction === 'horizontal';
return (
<div
ref={containerRef}
className={cn(
'flex min-h-0 min-w-0 h-full w-full',
isHorizontal ? 'flex-row' : 'flex-col',
className
)}
>
<div
className="min-h-0 min-w-0"
style={{ flex: `0 0 ${sizes[0]}%` }}
>
{children[0]}
</div>
<div
role="separator"
aria-orientation={direction}
onPointerDown={handlePointerDown}
className={cn(
isHorizontal
? 'w-2 cursor-col-resize border-l border-transparent'
: 'h-2 cursor-row-resize border-t border-transparent',
'bg-transparent hover:border-border/70',
isDragging && 'border-border',
handleClassName
)}
/>
<div
className="min-h-0 min-w-0"
style={{ flex: `1 1 ${sizes[1]}%` }}
>
{children[1]}
</div>
</div>
);
}
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { IconRefresh } from '@tabler/icons-react';
import { IconRefresh, IconExternalLink, IconLoader2 } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { usePreviewPanel } from '@/hooks/use-preview-panel';
@@ -23,6 +23,8 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
setPreviewUrlDraft,
isStopping,
handleStop,
detectedUrl,
isRunning,
} = usePreviewPanel({ sessionId, hasDevScript });
const closeLayoutPreview = useLayoutStore((state) => state.closePreview);
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
@@ -50,14 +52,21 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
const devProcess = useAppStore((state) =>
devProcessId ? state.processes.processesById[devProcessId] : undefined
);
const devOutput = useAppStore ((state) =>
const devOutput = useAppStore((state) =>
devProcessId ? state.processes.outputsByProcessId[devProcessId] ?? '' : ''
);
const [refreshKey, setRefreshKey] = useState(0);
const [showIframe, setShowIframe] = useState(false);
const [allowManualUrl, setAllowManualUrl] = useState(false);
const [showLoadingSpinner, setShowLoadingSpinner] = useState(true);
const isProcessFailed = devProcess?.status === 'failed';
const isProcessExited = devProcess?.status === 'exited';
// Check if we're waiting for a URL (for button spinner)
// Show spinner on "Preview" button when on output view and waiting for URL
const isWaitingForUrl = isRunning && previewView === 'output' && !previewUrl && showLoadingSpinner;
// Sync the view from localStorage to store if needed
useEffect(() => {
if (!sessionId) return;
@@ -71,6 +80,54 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
}
}, [sessionId, appStoreApi, setPreviewView]);
// 10-second timeout to enable manual URL entry and stop loading spinner when no URL detected
useEffect(() => {
if (!isRunning || detectedUrl) {
// Clear immediately using setTimeout to avoid synchronous setState in effect
const immediate = setTimeout(() => {
setAllowManualUrl(false);
setShowLoadingSpinner(true);
}, 0);
return () => clearTimeout(immediate);
}
// Reset loading spinner when starting to wait
const resetTimer = setTimeout(() => setShowLoadingSpinner(true), 0);
// After 10 seconds, enable manual URL, stop showing loading spinner, and switch to preview view
const timer = setTimeout(() => {
setAllowManualUrl(true);
setShowLoadingSpinner(false);
// Switch to preview view to show the "No URL detected" message
if (sessionId) {
setPreviewView(sessionId, 'preview');
}
}, 10000);
return () => {
clearTimeout(resetTimer);
clearTimeout(timer);
};
}, [isRunning, detectedUrl, sessionId, setPreviewView]);
// 2-second delay before showing iframe after URL detection
useEffect(() => {
if (!previewUrl) {
// Clear immediately using setTimeout to avoid synchronous setState in effect
const immediate = setTimeout(() => setShowIframe(false), 0);
return () => clearTimeout(immediate);
}
// Start hidden, then show after delay
const hideTimer = setTimeout(() => setShowIframe(false), 0);
const showTimer = setTimeout(() => setShowIframe(true), 2000);
return () => {
clearTimeout(hideTimer);
clearTimeout(showTimer);
};
}, [previewUrl, refreshKey]);
if (!sessionId) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground mr-[5px]">
@@ -101,28 +158,61 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
await handleStop();
};
const handleUrlSubmit = () => {
if (!sessionId) return;
const trimmed = previewUrlDraft.trim();
if (trimmed) {
setPreviewUrl(sessionId, trimmed);
}
};
const handleOpenInTab = () => {
if (!sessionId || !previewUrl) return;
// Open URL in new tab
window.open(previewUrl, '_blank', 'noopener,noreferrer');
// Close the preview panel but keep dev server running
setPreviewOpen(sessionId, false);
setPreviewStage(sessionId, 'closed');
setPreviewView(sessionId, 'preview');
setPreviewUrl(sessionId, '');
setPreviewUrlDraft(sessionId, '');
closeLayoutPreview(sessionId);
};
return (
<div className="h-full min-h-0 flex flex-col rounded-lg border border-border/70 bg-card p-3 mr-[5px]">
<div className="flex flex-wrap items-center gap-2">
<Input
value={previewUrlDraft}
onChange={(event) => setPreviewUrlDraft(sessionId, event.target.value)}
onBlur={() => setPreviewUrl(sessionId, previewUrlDraft)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
setPreviewUrl(sessionId, previewUrlDraft);
handleUrlSubmit();
}
}}
placeholder="http://localhost:3000"
className="h-8 flex-1 min-w-[240px]"
placeholder={detectedUrl || "http://localhost:3000"}
className="h-6 flex-1 min-w-[240px]"
/>
<Button
size="sm"
variant="outline"
onClick={handleOpenInTab}
disabled={!previewUrl}
className="cursor-pointer"
title="Open in browser tab"
>
<IconExternalLink className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setRefreshKey((value) => value + 1)}
disabled={!previewUrl}
className="cursor-pointer"
title="Refresh preview"
>
<IconRefresh className="h-4 w-4" />
</Button>
@@ -143,6 +233,9 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
setPreviewView(sessionId, previewView === 'output' ? 'preview' : 'output')
}
>
{isWaitingForUrl && (
<IconLoader2 className="h-4 w-4 mr-1 animate-spin" />
)}
{previewView === 'output' ? 'Preview' : 'Logs'}
</Button>
</div>
@@ -152,7 +245,7 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
<div className="h-full w-full">
<ProcessOutputTerminal output={devOutput} processId={devProcessId ?? null} />
</div>
) : previewUrl ? (
) : showIframe && previewUrl ? (
<div className="h-full w-full flex items-center justify-center overflow-hidden">
<div className="h-full w-full bg-background border border-border rounded-md overflow-hidden">
<iframe
@@ -165,9 +258,28 @@ export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
/>
</div>
</div>
) : previewUrl && !showIframe ? (
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-sm">Loading preview...</p>
</div>
) : isRunning && showLoadingSpinner ? (
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<p className="text-sm">Waiting for preview URL...</p>
</div>
) : isRunning ? (
<div className="h-full w-full flex flex-col items-center justify-center text-muted-foreground gap-2">
<p className="text-sm">No preview URL detected</p>
{allowManualUrl && !detectedUrl && (
<p className="text-sm text-muted-foreground/70 mt-2 max-w-md text-center">
You can manually enter a URL in the input above and press Enter.
</p>
)}
</div>
) : (
<div className="h-full w-full flex items-center justify-center text-muted-foreground">
Waiting for a preview URL…
Start the dev server to enable preview.
</div>
)}
</div>
+248 -246
View File
@@ -7,271 +7,273 @@ import { useLayoutStore } from '@/lib/state/layout-store';
import type { ProcessInfo } from '@/lib/types/http';
import type { ProcessStatusEntry } from '@/lib/state/store';
import { getLocalStorage } from '@/lib/local-storage';
import { detectPreviewUrlFromOutput } from '@/lib/preview-url-detector';
type UsePreviewPanelParams = {
sessionId: string | null;
hasDevScript?: boolean;
sessionId: string | null;
hasDevScript?: boolean;
};
function toProcessStatusEntry(process: ProcessInfo): ProcessStatusEntry {
return {
processId: process.id,
sessionId: process.session_id,
kind: process.kind,
scriptName: process.script_name,
status: process.status,
command: process.command,
workingDir: process.working_dir,
exitCode: process.exit_code ?? null,
startedAt: process.started_at,
updatedAt: process.updated_at,
};
return {
processId: process.id,
sessionId: process.session_id,
kind: process.kind,
scriptName: process.script_name,
status: process.status,
command: process.command,
workingDir: process.working_dir,
exitCode: process.exit_code ?? null,
startedAt: process.started_at,
updatedAt: process.updated_at,
};
}
export function usePreviewPanel({ sessionId, hasDevScript = false }: UsePreviewPanelParams) {
const [isStopping, setIsStopping] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const [hasRestoredState, setHasRestoredState] = useState(false);
const [isStartingOnRestore, setIsStartingOnRestore] = useState(false);
const [isStopping, setIsStopping] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const [hasRestoredState, setHasRestoredState] = useState(false);
const [isStartingOnRestore, setIsStartingOnRestore] = useState(false);
const processState = useAppStore((state) => state.processes);
const previewOpen = useAppStore((state) =>
sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false
);
const previewStage = useAppStore((state) =>
sessionId ? state.previewPanel.stageBySessionId[sessionId] ?? 'closed' : 'closed'
);
const previewUrl = useAppStore((state) =>
sessionId ? state.previewPanel.urlBySessionId[sessionId] ?? '' : ''
);
const previewUrlDraft = useAppStore((state) =>
sessionId ? state.previewPanel.urlDraftBySessionId[sessionId] ?? '' : ''
);
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
const appendProcessOutput = useAppStore((state) => state.appendProcessOutput);
const clearProcessOutput = useAppStore((state) => state.clearProcessOutput);
const setActiveProcess = useAppStore((state) => state.setActiveProcess);
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
const setPreviewView = useAppStore((state) => state.setPreviewView);
const applyLayoutPreset = useLayoutStore((state) => state.applyPreset);
const layoutState = useLayoutStore((state) =>
sessionId ? state.columnsBySessionId[sessionId] : null
);
const setPreviewUrl = useAppStore((state) => state.setPreviewUrl);
const setPreviewUrlDraft = useAppStore((state) => state.setPreviewUrlDraft);
const processState = useAppStore((state) => state.processes);
const previewOpen = useAppStore((state) =>
sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false
);
const previewStage = useAppStore((state) =>
sessionId ? state.previewPanel.stageBySessionId[sessionId] ?? 'closed' : 'closed'
);
const previewUrl = useAppStore((state) =>
sessionId ? state.previewPanel.urlBySessionId[sessionId] ?? '' : ''
);
const previewUrlDraft = useAppStore((state) =>
sessionId ? state.previewPanel.urlDraftBySessionId[sessionId] ?? '' : ''
);
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
const appendProcessOutput = useAppStore((state) => state.appendProcessOutput);
const clearProcessOutput = useAppStore((state) => state.clearProcessOutput);
const setActiveProcess = useAppStore((state) => state.setActiveProcess);
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
const setPreviewView = useAppStore((state) => state.setPreviewView);
const applyLayoutPreset = useLayoutStore((state) => state.applyPreset);
const layoutState = useLayoutStore((state) =>
sessionId ? state.columnsBySessionId[sessionId] : null
);
const setPreviewUrl = useAppStore((state) => state.setPreviewUrl);
const setPreviewUrlDraft = useAppStore((state) => state.setPreviewUrlDraft);
const devProcessId = useMemo(
() => (sessionId ? processState.devProcessBySessionId[sessionId] : undefined),
[processState.devProcessBySessionId, sessionId]
);
const devProcessId = useMemo(
() => (sessionId ? processState.devProcessBySessionId[sessionId] : undefined),
[processState.devProcessBySessionId, sessionId]
);
const devProcess = devProcessId ? processState.processesById[devProcessId] ?? null : null;
const devOutput = devProcessId ? processState.outputsByProcessId[devProcessId] ?? '' : '';
const devProcess = devProcessId ? processState.processesById[devProcessId] ?? null : null;
const devOutput = devProcessId ? processState.outputsByProcessId[devProcessId] ?? '' : '';
const detectedUrl = useMemo(() => {
if (!devOutput) return null;
const matches = devOutput.match(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?[^\s]*/g);
if (!matches || matches.length === 0) return null;
return matches[matches.length - 1];
}, [devOutput]);
const detectedUrl = useMemo(() => {
return detectPreviewUrlFromOutput(devOutput);
}, [devOutput]);
useEffect(() => {
if (!sessionId) return;
if (!previewOpen || previewStage !== 'logs') return;
if (!detectedUrl) return;
setPreviewUrl(sessionId, detectedUrl);
setPreviewUrlDraft(sessionId, detectedUrl);
setPreviewView(sessionId, 'preview');
setPreviewStage(sessionId, 'preview');
applyLayoutPreset(sessionId, 'preview');
}, [
sessionId,
previewOpen,
previewStage,
detectedUrl,
setPreviewUrl,
setPreviewUrlDraft,
setPreviewView,
setPreviewStage,
applyLayoutPreset,
]);
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
const loadProcesses = async () => {
try {
const processes = await listSessionProcesses(sessionId);
if (cancelled) return;
const sorted = processes.slice().sort((a, b) => {
if (a.kind === 'dev' && b.kind !== 'dev') return -1;
if (a.kind !== 'dev' && b.kind === 'dev') return 1;
return 0;
});
sorted.forEach((proc) => {
upsertProcessStatus(toProcessStatusEntry(proc));
});
setHasInitialized(true);
} catch {
// ignore
setHasInitialized(true);
}
};
loadProcesses();
return () => {
cancelled = true;
};
}, [sessionId, upsertProcessStatus]);
useEffect(() => {
if (!sessionId || !devProcessId) return;
let cancelled = false;
const loadOutput = async () => {
try {
const proc = await getSessionProcess(sessionId, devProcessId, true);
if (cancelled) return;
clearProcessOutput(devProcessId);
proc.output?.forEach((chunk) => {
appendProcessOutput(devProcessId, chunk.data);
});
} catch {
// ignore
}
};
loadOutput();
return () => {
cancelled = true;
};
}, [sessionId, devProcessId, clearProcessOutput, appendProcessOutput]);
// Restore preview state on mount if layout shows preview and dev process is running
useEffect(() => {
if (!sessionId || !hasInitialized || hasRestoredState || isStartingOnRestore) return;
const isPreviewLayout = layoutState?.preview === true;
const hasDevProcess = devProcessId !== undefined;
const isDevRunning = devProcess?.status === 'running' || devProcess?.status === 'starting';
// Restore the preview view mode from localStorage
const key = `preview-view:${sessionId}`;
const stored = getLocalStorage(key, null as string | null);
const restoredView: 'output' | 'preview' =
stored === 'output' || stored === 'preview' ? stored : 'output';
if (isPreviewLayout) {
if (hasDevProcess && isDevRunning) {
// Layout shows preview and dev is running - restore everything
if (!previewOpen) {
setPreviewOpen(sessionId, true);
}
setPreviewView(sessionId, restoredView);
if (previewStage === 'closed') {
setPreviewStage(sessionId, 'preview');
}
setHasRestoredState(true);
} else if (!hasDevProcess && hasDevScript) {
// Layout shows preview but no process - start one
setIsStartingOnRestore(true);
setPreviewOpen(sessionId, true);
setPreviewView(sessionId, restoredView);
useEffect(() => {
if (!sessionId) return;
if (!previewOpen || previewStage !== 'logs') return;
if (!detectedUrl) return;
setPreviewUrl(sessionId, detectedUrl);
setPreviewUrlDraft(sessionId, detectedUrl);
setPreviewView(sessionId, 'preview');
setPreviewStage(sessionId, 'preview');
applyLayoutPreset(sessionId, 'preview');
}, [
sessionId,
previewOpen,
previewStage,
detectedUrl,
setPreviewUrl,
setPreviewUrlDraft,
setPreviewView,
setPreviewStage,
applyLayoutPreset,
]);
startProcess(sessionId, { kind: 'dev' })
.then((resp) => {
if (resp?.process) {
const status = {
processId: resp.process.id,
sessionId: resp.process.session_id,
kind: resp.process.kind,
scriptName: resp.process.script_name,
status: resp.process.status,
command: resp.process.command,
workingDir: resp.process.working_dir,
exitCode: resp.process.exit_code ?? null,
startedAt: resp.process.started_at,
updatedAt: resp.process.updated_at,
};
upsertProcessStatus(status);
setActiveProcess(status.sessionId, status.processId);
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
const loadProcesses = async () => {
try {
const processes = await listSessionProcesses(sessionId);
if (cancelled) return;
const sorted = processes.slice().sort((a, b) => {
if (a.kind === 'dev' && b.kind !== 'dev') return -1;
if (a.kind !== 'dev' && b.kind === 'dev') return 1;
return 0;
});
sorted.forEach((proc) => {
upsertProcessStatus(toProcessStatusEntry(proc));
});
setHasInitialized(true);
} catch {
// ignore
setHasInitialized(true);
}
})
.finally(() => {
setIsStartingOnRestore(false);
};
loadProcesses();
return () => {
cancelled = true;
};
}, [sessionId, upsertProcessStatus]);
useEffect(() => {
if (!sessionId || !devProcessId) return;
let cancelled = false;
const loadOutput = async () => {
try {
const proc = await getSessionProcess(sessionId, devProcessId, true);
if (cancelled) return;
clearProcessOutput(devProcessId);
// Add script header if command exists
if (proc.command) {
appendProcessOutput(devProcessId, `${proc.command}\n\n`);
}
proc.output?.forEach((chunk) => {
appendProcessOutput(devProcessId, chunk.data);
});
} catch {
// ignore
}
};
loadOutput();
return () => {
cancelled = true;
};
}, [sessionId, devProcessId, clearProcessOutput, appendProcessOutput]);
// Restore preview state on mount if layout shows preview and dev process is running
useEffect(() => {
if (!sessionId || !hasInitialized || hasRestoredState || isStartingOnRestore) return;
const isPreviewLayout = layoutState?.preview === true;
const hasDevProcess = devProcessId !== undefined;
const isDevRunning = devProcess?.status === 'running' || devProcess?.status === 'starting';
// Restore the preview view mode from localStorage
const key = `preview-view:${sessionId}`;
const stored = getLocalStorage(key, null as string | null);
const restoredView: 'output' | 'preview' =
stored === 'output' || stored === 'preview' ? stored : 'output';
if (isPreviewLayout) {
if (hasDevProcess && isDevRunning) {
// Layout shows preview and dev is running - restore everything
if (!previewOpen) {
setPreviewOpen(sessionId, true);
}
setPreviewView(sessionId, restoredView);
if (previewStage === 'closed') {
setPreviewStage(sessionId, 'preview');
}
setHasRestoredState(true);
} else if (!hasDevProcess && hasDevScript) {
// Layout shows preview but no process - start one
setIsStartingOnRestore(true);
setPreviewOpen(sessionId, true);
setPreviewView(sessionId, restoredView);
setPreviewStage(sessionId, 'preview');
startProcess(sessionId, { kind: 'dev' })
.then((resp) => {
if (resp?.process) {
const status = {
processId: resp.process.id,
sessionId: resp.process.session_id,
kind: resp.process.kind,
scriptName: resp.process.script_name,
status: resp.process.status,
command: resp.process.command,
workingDir: resp.process.working_dir,
exitCode: resp.process.exit_code ?? null,
startedAt: resp.process.started_at,
updatedAt: resp.process.updated_at,
};
upsertProcessStatus(status);
setActiveProcess(status.sessionId, status.processId);
}
})
.finally(() => {
setIsStartingOnRestore(false);
setHasRestoredState(true);
});
} else {
// Layout shows preview but process exists and isn't running - still restore view
setPreviewView(sessionId, restoredView);
setHasRestoredState(true);
}
} else {
// No preview in layout - mark as restored so we don't keep checking
setHasRestoredState(true);
});
} else {
// Layout shows preview but process exists and isn't running - still restore view
setPreviewView(sessionId, restoredView);
setHasRestoredState(true);
}
} else {
// No preview in layout - mark as restored so we don't keep checking
setHasRestoredState(true);
}
}, [
sessionId,
hasInitialized,
hasRestoredState,
isStartingOnRestore,
previewOpen,
layoutState,
devProcessId,
devProcess,
previewStage,
hasDevScript,
setPreviewOpen,
setPreviewView,
setPreviewStage,
upsertProcessStatus,
setActiveProcess,
]);
}
}, [
sessionId,
hasInitialized,
hasRestoredState,
isStartingOnRestore,
previewOpen,
layoutState,
devProcessId,
devProcess,
previewStage,
hasDevScript,
setPreviewOpen,
setPreviewView,
setPreviewStage,
upsertProcessStatus,
setActiveProcess,
]);
useEffect(() => {
if (!sessionId) return;
if (!detectedUrl) return;
if (!previewOpen && previewStage === 'closed') {
return;
}
if (!previewOpen) {
setPreviewOpen(sessionId, true);
}
if (!previewUrl) {
setPreviewUrl(sessionId, detectedUrl);
setPreviewUrlDraft(sessionId, detectedUrl);
}
}, [
sessionId,
previewOpen,
detectedUrl,
previewUrl,
previewStage,
setPreviewOpen,
setPreviewUrl,
setPreviewUrlDraft,
]);
useEffect(() => {
if (!sessionId) return;
if (!detectedUrl) return;
if (!previewOpen && previewStage === 'closed') {
return;
}
if (!previewOpen) {
setPreviewOpen(sessionId, true);
}
if (!previewUrl) {
setPreviewUrl(sessionId, detectedUrl);
setPreviewUrlDraft(sessionId, detectedUrl);
}
}, [
sessionId,
previewOpen,
detectedUrl,
previewUrl,
previewStage,
setPreviewOpen,
setPreviewUrl,
setPreviewUrlDraft,
]);
const handleStop = async () => {
if (!sessionId || !devProcess) return;
setIsStopping(true);
try {
await stopProcess(sessionId, { process_id: devProcess.processId });
} finally {
setIsStopping(false);
}
};
const handleStop = async () => {
if (!sessionId || !devProcess) return;
setIsStopping(true);
try {
await stopProcess(sessionId, { process_id: devProcess.processId });
} finally {
setIsStopping(false);
}
};
const isRunning = devProcess?.status === 'running' || devProcess?.status === 'starting';
const isRunning = devProcess?.status === 'running' || devProcess?.status === 'starting';
return {
previewStage,
previewUrl,
previewUrlDraft,
setPreviewUrl,
setPreviewUrlDraft,
detectedUrl,
isRunning,
isStopping,
handleStop,
};
return {
previewStage,
previewUrl,
previewUrlDraft,
setPreviewUrl,
setPreviewUrlDraft,
detectedUrl,
isRunning,
isStopping,
handleStop,
};
}
+257
View File
@@ -0,0 +1,257 @@
import { describe, expect, it } from 'vitest';
import { detectPreviewUrl, detectPreviewUrlFromOutput } from './preview-url-detector';
describe('detectPreviewUrl', () => {
describe('full URL patterns', () => {
it('detects localhost URL with port', () => {
const result = detectPreviewUrl('Server running at http://localhost:3000');
expect(result).toEqual({
url: 'http://localhost:3000/',
port: 3000,
scheme: 'http',
});
});
it('detects 127.0.0.1 URL with port', () => {
const result = detectPreviewUrl('Listening on http://127.0.0.1:8080');
expect(result).toEqual({
url: 'http://127.0.0.1:8080/',
port: 8080,
scheme: 'http',
});
});
it('detects 0.0.0.0 URL with port', () => {
const result = detectPreviewUrl('Server at http://0.0.0.0:4000');
expect(result).toEqual({
url: 'http://0.0.0.0:4000/',
port: 4000,
scheme: 'http',
});
});
it('detects HTTPS URLs', () => {
const result = detectPreviewUrl('Ready on https://localhost:3000');
expect(result).toEqual({
url: 'https://localhost:3000/',
port: 3000,
scheme: 'https',
});
});
it('detects URLs with paths', () => {
const result = detectPreviewUrl('Visit http://localhost:3000/admin');
expect(result).toEqual({
url: 'http://localhost:3000/admin',
port: 3000,
scheme: 'http',
});
});
it('rejects localhost URL without port', () => {
const result = detectPreviewUrl('Server running at http://localhost');
expect(result).toBeNull();
});
it('rejects 127.0.0.1 URL without port', () => {
const result = detectPreviewUrl('Available at http://127.0.0.1');
expect(result).toBeNull();
});
it('rejects 0.0.0.0 URL without port', () => {
const result = detectPreviewUrl('Listening on http://0.0.0.0');
expect(result).toBeNull();
});
});
describe('host:port patterns', () => {
it('detects localhost:port pattern', () => {
const result = detectPreviewUrl('Server started on localhost:3000');
expect(result).toEqual({
url: 'http://localhost:3000',
port: 3000,
scheme: 'http',
});
});
it('detects 127.0.0.1:port pattern', () => {
const result = detectPreviewUrl('Bound to 127.0.0.1:8080');
expect(result).toEqual({
url: 'http://127.0.0.1:8080',
port: 8080,
scheme: 'http',
});
});
it('detects 0.0.0.0:port pattern', () => {
const result = detectPreviewUrl('Listening 0.0.0.0:4000');
expect(result).toEqual({
url: 'http://0.0.0.0:4000',
port: 4000,
scheme: 'http',
});
});
it('infers https from context', () => {
const result = detectPreviewUrl('HTTPS server on localhost:3000');
expect(result).toEqual({
url: 'https://localhost:3000',
port: 3000,
scheme: 'https',
});
});
it('handles multi-digit ports', () => {
const result = detectPreviewUrl('Running on localhost:12345');
expect(result).toEqual({
url: 'http://localhost:12345',
port: 12345,
scheme: 'http',
});
});
});
describe('edge cases', () => {
it('returns null for empty string', () => {
expect(detectPreviewUrl('')).toBeNull();
});
it('returns null for non-matching text', () => {
expect(detectPreviewUrl('Server is starting...')).toBeNull();
});
it('returns null for invalid URLs', () => {
expect(detectPreviewUrl('Invalid: http://[::1]:abc')).toBeNull();
});
it('handles URLs with special characters', () => {
const result = detectPreviewUrl('Ready: http://localhost:3000?debug=true');
expect(result?.url).toBe('http://localhost:3000/?debug=true');
});
it('handles multiple URLs in one line (returns first valid)', () => {
const result = detectPreviewUrl(
'Server at http://localhost:3000 and http://localhost:3001'
);
expect(result?.port).toBe(3000);
});
it('handles ANSI color codes', () => {
const result = detectPreviewUrl('\x1b[32mRunning at http://localhost:3000\x1b[0m');
expect(result?.port).toBe(3000);
});
});
describe('real-world examples', () => {
it('detects Next.js dev server', () => {
const result = detectPreviewUrl(' ▲ Local: http://localhost:3000');
expect(result?.url).toBe('http://localhost:3000/');
});
it('detects Vite dev server', () => {
const result = detectPreviewUrl(' ➜ Local: http://localhost:5173/');
expect(result?.url).toBe('http://localhost:5173/');
});
it('detects Create React App', () => {
const result = detectPreviewUrl('On Your Network: http://localhost:3000');
expect(result?.url).toBe('http://localhost:3000/');
});
it('detects Rails server', () => {
const result = detectPreviewUrl('* Listening on http://127.0.0.1:3000');
expect(result?.url).toBe('http://127.0.0.1:3000/');
});
it('detects Django dev server', () => {
const result = detectPreviewUrl('Starting development server at http://127.0.0.1:8000/');
expect(result?.url).toBe('http://127.0.0.1:8000/');
});
it('detects Express server', () => {
const result = detectPreviewUrl('Server listening on localhost:3000');
expect(result?.url).toBe('http://localhost:3000');
});
it('detects Flask dev server', () => {
const result = detectPreviewUrl(' * Running on http://127.0.0.1:5000');
expect(result?.url).toBe('http://127.0.0.1:5000/');
});
});
});
describe('detectPreviewUrlFromOutput', () => {
it('returns null for empty output', () => {
expect(detectPreviewUrlFromOutput('')).toBeNull();
});
it('finds URL in multi-line output', () => {
const output = `
Starting server...
Compiling...
Server running at http://localhost:3000
Ready!
`;
expect(detectPreviewUrlFromOutput(output)).toBe('http://localhost:3000/');
});
it('returns the last valid URL when multiple exist', () => {
const output = `
Starting on http://localhost:3000
Error: Port in use
Starting on http://localhost:3001
Ready!
`;
expect(detectPreviewUrlFromOutput(output)).toBe('http://localhost:3001/');
});
it('skips invalid URLs and finds valid ones', () => {
const output = `
Trying http://localhost
Failed
Trying http://localhost:3000
Success!
`;
expect(detectPreviewUrlFromOutput(output)).toBe('http://localhost:3000/');
});
it('handles output with no valid URLs', () => {
const output = `
Server starting...
Initializing...
Done
`;
expect(detectPreviewUrlFromOutput(output)).toBeNull();
});
it('handles real Next.js output', () => {
const output = `
▲ Next.js 14.0.0
- Local: http://localhost:3000
- Environments: .env
✓ Ready in 1.5s
`;
expect(detectPreviewUrlFromOutput(output)).toBe('http://localhost:3000/');
});
it('handles real Vite output', () => {
const output = `
VITE v5.0.0 ready in 500 ms
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
`;
expect(detectPreviewUrlFromOutput(output)).toBe('http://localhost:5173/');
});
it('ignores URLs without ports mixed with valid ones', () => {
const output = `
Checking http://localhost
Port available
Starting on localhost:3000
Ready!
`;
expect(detectPreviewUrlFromOutput(output)).toBe('http://localhost:3000');
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Detects and validates dev server URLs from process output logs.
* Handles various formats including full URLs and host:port patterns.
*/
export interface PreviewUrlInfo {
url: string;
port?: number;
scheme: 'http' | 'https';
}
/**
* Detects a preview URL from a line of process output.
*
* Rules:
* - Rejects localhost URLs without a port (e.g., http://localhost)
* - Accepts full URLs with ports (e.g., http://localhost:3000)
* - Accepts host:port patterns (e.g., localhost:3000)
* - Supports localhost, 127.0.0.1, and 0.0.0.0
*
* @param line - A line of process output to scan
* @returns PreviewUrlInfo if a valid URL is found, null otherwise
*/
export function detectPreviewUrl(line: string): PreviewUrlInfo | null {
// Pattern for full URLs (e.g., http://localhost:3000, https://127.0.0.1:8080)
const fullUrlPattern = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(?::\d+)?[^\s]*/gi;
// Pattern for host:port (e.g., localhost:3000, 0.0.0.0:8080)
const hostPortPattern = /(?:localhost|127\.0\.0\.1|0\.0\.0\.0):(\d{2,5})/gi;
// Try to match full URLs first
const fullUrlMatches = line.match(fullUrlPattern);
if (fullUrlMatches && fullUrlMatches.length > 0) {
// Filter out localhost URLs without port - they're not valid dev server URLs
for (const match of fullUrlMatches) {
try {
const parsed = new URL(match);
const isLocalhost = ['localhost', '127.0.0.1', '0.0.0.0'].includes(parsed.hostname);
// Reject localhost URLs without a port
if (isLocalhost && !parsed.port) {
continue; // Try next match or fall through to host:port pattern
}
return {
url: parsed.toString(),
port: parsed.port ? Number(parsed.port) : undefined,
scheme: parsed.protocol === 'https:' ? 'https' : 'http',
};
} catch {
// Invalid URL, continue to next match
continue;
}
}
}
// Fall back to host:port pattern
const hostPortMatches = line.match(hostPortPattern);
if (hostPortMatches && hostPortMatches.length > 0) {
const match = hostPortMatches[hostPortMatches.length - 1];
const portMatch = match.match(/:(\d{2,5})$/);
const port = portMatch ? Number(portMatch[1]) : undefined;
// Infer scheme from context (look for 'https' in the line)
const scheme = /https/i.test(line) ? 'https' : 'http';
return {
url: `${scheme}://${match}`,
port,
scheme,
};
}
return null;
}
/**
* Scans process output for dev server URLs.
* Returns the last valid URL found.
*
* @param output - The full process output to scan
* @returns The URL string if found, null otherwise
*/
export function detectPreviewUrlFromOutput(output: string): string | null {
if (!output) return null;
const lines = output.split('\n');
let lastValidUrl: string | null = null;
for (const line of lines) {
const urlInfo = detectPreviewUrl(line);
if (urlInfo) {
lastValidUrl = urlInfo.url;
}
}
return lastValidUrl;
}