feat: override auto-detected preview url (#1573)

* override auto-detected preview url

* i18n

* remove redundant useMemo
This commit is contained in:
Gabriel Gordon-Hall
2026-01-05 18:56:04 +00:00
committed by GitHub
parent 48d2ce1b80
commit 7224376170
7 changed files with 121 additions and 22 deletions

View File

@@ -23,6 +23,7 @@ export function PreviewPanel() {
const [showHelp, setShowHelp] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [showLogs, setShowLogs] = useState(false);
const [customUrl, setCustomUrl] = useState<string | null>(null);
const listenerRef = useRef<ClickToComponentListener | null>(null);
const { t } = useTranslation('tasks');
@@ -51,6 +52,9 @@ export function PreviewPanel() {
lastKnownUrl,
});
// Compute effective URL - custom URL overrides auto-detected
const effectiveUrl = customUrl ?? previewState.url;
const handleRefresh = () => {
setIframeError(false);
setRefreshKey((prev) => prev + 1);
@@ -62,8 +66,8 @@ export function PreviewPanel() {
const { addElement } = useClickedElements();
const handleCopyUrl = async () => {
if (previewState.url) {
await navigator.clipboard.writeText(previewState.url);
if (effectiveUrl) {
await navigator.clipboard.writeText(effectiveUrl);
}
};
@@ -117,12 +121,12 @@ export function PreviewPanel() {
}, [loadingTimeFinished, isReady, latestDevServerProcess, runningDevServer]);
const isPreviewReady =
previewState.status === 'ready' &&
Boolean(previewState.url) &&
!iframeError;
(previewState.status === 'ready' && Boolean(previewState.url)) ||
(customUrl !== null && runningDevServer);
const isPreviewReadyWithoutError = isPreviewReady && !iframeError;
const mode = iframeError
? 'error'
: isPreviewReady
: isPreviewReadyWithoutError
? 'ready'
: runningDevServer
? 'searching'
@@ -165,15 +169,18 @@ export function PreviewPanel() {
<>
<PreviewToolbar
mode={mode}
url={previewState.url}
url={effectiveUrl}
onRefresh={handleRefresh}
onCopyUrl={handleCopyUrl}
onStop={stopDevServer}
isStopping={isStoppingDevServer}
customUrl={customUrl}
detectedUrl={lastKnownUrl?.url}
onUrlChange={setCustomUrl}
/>
<ReadyContent
url={previewState.url}
iframeKey={`${previewState.url}-${refreshKey}`}
url={effectiveUrl}
iframeKey={`${effectiveUrl}-${refreshKey}`}
onIframeError={handleIframeError}
/>
</>

View File

@@ -1,4 +1,5 @@
import { ExternalLink, RefreshCw, Copy, Loader2, Pause } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { ExternalLink, RefreshCw, Copy, Loader2, Pause, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import {
@@ -8,6 +9,7 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { NewCardHeader } from '@/components/ui/new-card';
import { Input } from '@/components/ui/input';
interface PreviewToolbarProps {
mode: 'noServer' | 'error' | 'ready';
@@ -16,6 +18,9 @@ interface PreviewToolbarProps {
onCopyUrl: () => void;
onStop: () => void;
isStopping?: boolean;
customUrl: string | null;
detectedUrl: string | undefined;
onUrlChange: (url: string | null) => void;
}
export function PreviewToolbar({
@@ -25,8 +30,49 @@ export function PreviewToolbar({
onCopyUrl,
onStop,
isStopping,
customUrl,
detectedUrl,
onUrlChange,
}: PreviewToolbarProps) {
const { t } = useTranslation('tasks');
const [isEditing, setIsEditing] = useState(false);
const [urlInput, setUrlInput] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleStartEdit = () => {
setUrlInput(url ?? '');
setIsEditing(true);
};
const handleSubmit = () => {
const trimmed = urlInput.trim();
if (!trimmed || trimmed === detectedUrl) {
// Empty input or detected URL: reset to detected
onUrlChange(null);
} else {
onUrlChange(trimmed);
}
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSubmit();
} else if (e.key === 'Escape') {
setIsEditing(false);
}
};
const handleClearCustomUrl = () => {
onUrlChange(null);
};
const actions =
mode !== 'noServer' ? (
@@ -119,13 +165,49 @@ export function PreviewToolbar({
return (
<NewCardHeader className="shrink-0" actions={actions}>
<div className="flex items-center">
<span
className="text-sm text-muted-foreground font-mono truncate whitespace-nowrap"
aria-live="polite"
>
{url || <Loader2 className="h-4 w-4 animate-spin" />}
</span>
<div className="flex items-center gap-2 min-w-0">
{isEditing ? (
<Input
ref={inputRef}
type="text"
value={urlInput}
onChange={(e) => setUrlInput(e.target.value)}
onBlur={handleSubmit}
onKeyDown={handleKeyDown}
className="h-7 text-sm font-mono flex-1"
placeholder="http://localhost:3000"
/>
) : (
<>
<button
onClick={handleStartEdit}
className="text-sm text-muted-foreground font-mono truncate hover:text-foreground transition-colors cursor-text text-left"
aria-live="polite"
title={t('preview.toolbar.clickToEdit')}
>
{url || <Loader2 className="h-4 w-4 animate-spin" />}
</button>
{customUrl !== null && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleClearCustomUrl}
className="h-5 w-5 p-0 shrink-0"
>
<X className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{t('preview.toolbar.resetUrl')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</>
)}
</div>
</NewCardHeader>
);

View File

@@ -102,7 +102,9 @@
"refresh": "Refresh preview",
"copyUrl": "Copy URL",
"openInTab": "Open in new tab",
"stopDevServer": "Stop dev server"
"stopDevServer": "Stop dev server",
"clickToEdit": "Click to edit URL",
"resetUrl": "Reset to detected URL"
}
},
"diff": {

View File

@@ -312,7 +312,9 @@
"copyUrl": "Copiar URL",
"openInTab": "Abrir en nueva pestaña",
"refresh": "Actualizar vista previa",
"stopDevServer": "Detener servidor de desarrollo"
"stopDevServer": "Detener servidor de desarrollo",
"clickToEdit": "Clic para editar URL",
"resetUrl": "Restablecer a URL detectada"
},
"troubleAlert": {
"item1": "¿Se inició correctamente el servidor de desarrollo? Puede haber un error que necesites resolver, o quizás sea necesario instalar dependencias.",

View File

@@ -312,7 +312,9 @@
"copyUrl": "URLをコピー",
"openInTab": "新しいタブで開く",
"refresh": "プレビューを更新",
"stopDevServer": "開発サーバーを停止"
"stopDevServer": "開発サーバーを停止",
"clickToEdit": "クリックしてURLを編集",
"resetUrl": "検出されたURLにリセット"
},
"troubleAlert": {
"item1": "開発サーバーが正常に起動しましたか?解決すべきバグがあるか、依存関係のインストールが必要な可能性があります。",

View File

@@ -312,7 +312,9 @@
"copyUrl": "URL 복사",
"openInTab": "새 탭에서 열기",
"refresh": "미리보기 새로고침",
"stopDevServer": "개발 서버 중지"
"stopDevServer": "개발 서버 중지",
"clickToEdit": "URL 편집하려면 클릭",
"resetUrl": "감지된 URL로 재설정"
},
"troubleAlert": {
"item1": "개발 서버가 성공적으로 시작되었나요? 해결해야 할 버그가 있거나 종속성을 설치해야 할 수 있습니다.",

View File

@@ -115,7 +115,9 @@
"refresh": "刷新预览",
"copyUrl": "复制 URL",
"openInTab": "在新标签页中打开",
"stopDevServer": "停止开发服务器"
"stopDevServer": "停止开发服务器",
"clickToEdit": "点击编辑 URL",
"resetUrl": "重置为检测到的 URL"
}
},
"diff": {