Allow manual URL override in dev server preview (Vibe Kanban) (#1989)
* The implementation is complete and tested. Here's a summary of the changes made: I've implemented an editable URL bar in the Preview Controls that allows users to override the auto-detected dev server URL on a per-workspace basis. | File | Changes | |------|---------| | `crates/db/src/models/scratch.rs` | Added `PreviewUrlOverrideData` struct and `PreviewUrlOverride` variant to `ScratchPayload` enum | | `crates/server/src/bin/generate_types.rs` | Registered `PreviewUrlOverrideData::decl()` for TypeScript type generation | | `shared/types.ts` | Auto-generated with new `PreviewUrlOverrideData` type and updated `ScratchPayload` union | | `frontend/src/hooks/usePreviewUrlOverride.ts` | **New file** - Custom hook for managing URL override persistence via scratch system | | `frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx` | Added URL override hook integration, merged auto-detected with override URL | | `frontend/src/components/ui-new/views/PreviewControls.tsx` | Replaced static `<span>` with editable `<input>`, added clear override button (X icon) | - **Editable URL bar**: Users can type a custom URL to override the auto-detected one - **Per-workspace persistence**: Override URL saved in the scratch system using workspace ID - **Debounced saves**: Changes are debounced (300ms) before saving to avoid excessive API calls - **Clear override button**: X button appears when override is active, allowing revert to auto-detected URL - **Visual distinction**: Override URLs show in normal text color, auto-detected in dim text * Cleanup script changes for workspace 3f3c315d-5cc2-43b0-9043-117c857f38b4 * The URL input fix is complete. Here's a summary of what was implemented: **Feature: Editable URL bar with per-workspace persistence** 1. **New scratch type** - Added `PreviewUrlOverride` to the Rust scratch system for persisting URL overrides per workspace 2. **New hook** - Created `usePreviewUrlOverride.ts` that wraps the scratch system with debounced saves (300ms delay) 3. **Container integration** - Updated `PreviewControlsContainer.tsx` to use the override URL when set, falling back to auto-detected URL 4. **View updates** - Updated `PreviewControls.tsx` with: - Editable input field instead of read-only display - Clear button (X icon) to revert to auto-detected URL - Local state management to prevent WebSocket updates from disrupting typing **Bug fix for typing issue:** - Added local state (`localValue`) and ref (`inputRef`) to track input value locally - The effect only syncs from server when input is not focused, preventing cursor jumping and character loss during typing * ESLint and Clippy both pass now. The fix moved the state management from the presentational view component (`PreviewControls.tsx`) to the container component (`PreviewControlsContainer.tsx`), which aligns with the codebase's architecture pattern of keeping views stateless. * I can see projects now. Let me click on the first project to access a workspace where I can test the URL override feature: * TypeScript check passes now. The fix was changing `RefObject<HTMLInputElement | null>` to `RefObject<HTMLInputElement>` in the props interface - the `null` is already implied in how React refs work.
This commit is contained in:
committed by
GitHub
parent
d9fa2d1fa5
commit
c7cf72a57f
@@ -25,6 +25,12 @@ pub struct DraftFollowUpData {
|
||||
pub variant: Option<String>,
|
||||
}
|
||||
|
||||
/// Data for a preview URL override scratch
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
pub struct PreviewUrlOverrideData {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Data for a draft workspace scratch (new workspace creation)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
pub struct DraftWorkspaceData {
|
||||
@@ -57,6 +63,7 @@ pub enum ScratchPayload {
|
||||
DraftTask(String),
|
||||
DraftFollowUp(DraftFollowUpData),
|
||||
DraftWorkspace(DraftWorkspaceData),
|
||||
PreviewUrlOverride(PreviewUrlOverrideData),
|
||||
}
|
||||
|
||||
impl ScratchPayload {
|
||||
|
||||
@@ -39,6 +39,7 @@ fn generate_types_content() -> String {
|
||||
db::models::scratch::DraftFollowUpData::decl(),
|
||||
db::models::scratch::DraftWorkspaceData::decl(),
|
||||
db::models::scratch::DraftWorkspaceRepo::decl(),
|
||||
db::models::scratch::PreviewUrlOverrideData::decl(),
|
||||
db::models::scratch::ScratchPayload::decl(),
|
||||
db::models::scratch::ScratchType::decl(),
|
||||
db::models::scratch::Scratch::decl(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
|
||||
import { PreviewBrowser } from '../views/PreviewBrowser';
|
||||
import { usePreviewDevServer } from '../hooks/usePreviewDevServer';
|
||||
import { usePreviewUrl } from '../hooks/usePreviewUrl';
|
||||
import { usePreviewUrlOverride } from '@/hooks/usePreviewUrlOverride';
|
||||
import { useLogStream } from '@/hooks/useLogStream';
|
||||
import { useLayoutStore } from '@/stores/useLayoutStore';
|
||||
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
||||
@@ -19,7 +20,7 @@ export function PreviewBrowserContainer({
|
||||
}: PreviewBrowserContainerProps) {
|
||||
const navigate = useNavigate();
|
||||
const previewRefreshKey = useLayoutStore((s) => s.previewRefreshKey);
|
||||
const { repos } = useWorkspaceContext();
|
||||
const { repos, workspaceId } = useWorkspaceContext();
|
||||
|
||||
const { start, isStarting, runningDevServers, devServerProcesses } =
|
||||
usePreviewDevServer(attemptId);
|
||||
@@ -28,13 +29,19 @@ export function PreviewBrowserContainer({
|
||||
const { logs } = useLogStream(primaryDevServer?.id ?? '');
|
||||
const urlInfo = usePreviewUrl(logs);
|
||||
|
||||
// URL override for this workspace
|
||||
const { overrideUrl, hasOverride } = usePreviewUrlOverride(workspaceId);
|
||||
|
||||
// Use override URL if set, otherwise fall back to auto-detected
|
||||
const effectiveUrl = hasOverride ? overrideUrl : urlInfo?.url;
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
start();
|
||||
}, [start]);
|
||||
|
||||
// Use previewRefreshKey from store to force iframe reload
|
||||
const iframeUrl = urlInfo?.url
|
||||
? `${urlInfo.url}${urlInfo.url.includes('?') ? '&' : '?'}_refresh=${previewRefreshKey}`
|
||||
const iframeUrl = effectiveUrl
|
||||
? `${effectiveUrl}${effectiveUrl.includes('?') ? '&' : '?'}_refresh=${previewRefreshKey}`
|
||||
: undefined;
|
||||
|
||||
const handleEditDevScript = () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useState, useEffect } from 'react';
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { PreviewControls } from '../views/PreviewControls';
|
||||
import { usePreviewDevServer } from '../hooks/usePreviewDevServer';
|
||||
import { usePreviewUrl } from '../hooks/usePreviewUrl';
|
||||
import { usePreviewUrlOverride } from '@/hooks/usePreviewUrlOverride';
|
||||
import { useLogStream } from '@/hooks/useLogStream';
|
||||
import { useLayoutStore } from '@/stores/useLayoutStore';
|
||||
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
||||
@@ -18,7 +19,7 @@ export function PreviewControlsContainer({
|
||||
onViewProcessInPanel,
|
||||
className,
|
||||
}: PreviewControlsContainerProps) {
|
||||
const { repos } = useWorkspaceContext();
|
||||
const { repos, workspaceId } = useWorkspaceContext();
|
||||
const setLogsMode = useLayoutStore((s) => s.setLogsMode);
|
||||
const triggerPreviewRefresh = useLayoutStore((s) => s.triggerPreviewRefresh);
|
||||
|
||||
@@ -49,6 +50,32 @@ export function PreviewControlsContainer({
|
||||
const { logs: primaryLogs } = useLogStream(primaryDevServer?.id ?? '');
|
||||
const urlInfo = usePreviewUrl(primaryLogs);
|
||||
|
||||
// URL override for this workspace
|
||||
const { overrideUrl, setOverrideUrl, clearOverride, hasOverride } =
|
||||
usePreviewUrlOverride(workspaceId);
|
||||
|
||||
// Use override URL if set, otherwise fall back to auto-detected
|
||||
const effectiveUrl = hasOverride ? overrideUrl : urlInfo?.url;
|
||||
|
||||
// Local state for URL input to prevent WebSocket updates from disrupting typing
|
||||
const urlInputRef = useRef<HTMLInputElement>(null);
|
||||
const [urlInputValue, setUrlInputValue] = useState(effectiveUrl ?? '');
|
||||
|
||||
// Sync from prop only when input is not focused
|
||||
useEffect(() => {
|
||||
if (document.activeElement !== urlInputRef.current) {
|
||||
setUrlInputValue(effectiveUrl ?? '');
|
||||
}
|
||||
}, [effectiveUrl]);
|
||||
|
||||
const handleUrlInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setUrlInputValue(value);
|
||||
setOverrideUrl(value);
|
||||
},
|
||||
[setOverrideUrl]
|
||||
);
|
||||
|
||||
const handleViewFullLogs = useCallback(
|
||||
(processId?: string) => {
|
||||
const targetId = processId ?? activeProcess?.id;
|
||||
@@ -77,17 +104,21 @@ export function PreviewControlsContainer({
|
||||
triggerPreviewRefresh();
|
||||
}, [triggerPreviewRefresh]);
|
||||
|
||||
const handleClearOverride = useCallback(async () => {
|
||||
await clearOverride();
|
||||
}, [clearOverride]);
|
||||
|
||||
const handleCopyUrl = useCallback(async () => {
|
||||
if (urlInfo?.url) {
|
||||
await navigator.clipboard.writeText(urlInfo.url);
|
||||
if (effectiveUrl) {
|
||||
await navigator.clipboard.writeText(effectiveUrl);
|
||||
}
|
||||
}, [urlInfo?.url]);
|
||||
}, [effectiveUrl]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
if (urlInfo?.url) {
|
||||
window.open(urlInfo.url, '_blank');
|
||||
if (effectiveUrl) {
|
||||
window.open(effectiveUrl, '_blank');
|
||||
}
|
||||
}, [urlInfo?.url]);
|
||||
}, [effectiveUrl]);
|
||||
|
||||
const handleFixScript = useCallback(() => {
|
||||
if (!attemptId || repos.length === 0) return;
|
||||
@@ -123,7 +154,13 @@ export function PreviewControlsContainer({
|
||||
activeProcessId={activeProcess?.id ?? null}
|
||||
logs={logs}
|
||||
logsError={logsError}
|
||||
url={urlInfo?.url}
|
||||
url={effectiveUrl ?? undefined}
|
||||
autoDetectedUrl={urlInfo?.url}
|
||||
isUsingOverride={hasOverride}
|
||||
urlInputValue={urlInputValue}
|
||||
urlInputRef={urlInputRef}
|
||||
onUrlInputChange={handleUrlInputChange}
|
||||
onClearOverride={handleClearOverride}
|
||||
onViewFullLogs={handleViewFullLogs}
|
||||
onTabChange={handleTabChange}
|
||||
onStart={handleStart}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RefObject } from 'react';
|
||||
import {
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
@@ -6,6 +7,7 @@ import {
|
||||
SpinnerIcon,
|
||||
CopyIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -24,6 +26,12 @@ interface PreviewControlsProps {
|
||||
logs: LogEntry[];
|
||||
logsError: string | null;
|
||||
url?: string;
|
||||
autoDetectedUrl?: string;
|
||||
isUsingOverride?: boolean;
|
||||
urlInputValue: string;
|
||||
urlInputRef: RefObject<HTMLInputElement>;
|
||||
onUrlInputChange: (value: string) => void;
|
||||
onClearOverride?: () => void;
|
||||
onViewFullLogs: () => void;
|
||||
onTabChange: (processId: string) => void;
|
||||
onStart: () => void;
|
||||
@@ -44,6 +52,12 @@ export function PreviewControls({
|
||||
logs,
|
||||
logsError,
|
||||
url,
|
||||
autoDetectedUrl,
|
||||
isUsingOverride,
|
||||
urlInputValue,
|
||||
urlInputRef,
|
||||
onUrlInputChange,
|
||||
onClearOverride,
|
||||
onViewFullLogs,
|
||||
onTabChange,
|
||||
onStart,
|
||||
@@ -73,11 +87,32 @@ export function PreviewControls({
|
||||
contentClassName="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-half p-base">
|
||||
{url && (
|
||||
{(url || autoDetectedUrl) && (
|
||||
<div className="flex items-center gap-half bg-panel rounded-sm px-base py-half flex-1 min-w-0">
|
||||
<span className="flex-1 font-mono text-sm text-low truncate">
|
||||
{url}
|
||||
</span>
|
||||
<input
|
||||
ref={urlInputRef}
|
||||
type="text"
|
||||
value={urlInputValue}
|
||||
onChange={(e) => onUrlInputChange(e.target.value)}
|
||||
placeholder={autoDetectedUrl ?? 'Enter URL...'}
|
||||
className={cn(
|
||||
'flex-1 font-mono text-sm bg-transparent border-none outline-none min-w-0',
|
||||
isUsingOverride
|
||||
? 'text-normal'
|
||||
: 'text-low placeholder:text-low'
|
||||
)}
|
||||
/>
|
||||
{isUsingOverride && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearOverride}
|
||||
className="text-low hover:text-normal"
|
||||
aria-label="Clear URL override"
|
||||
title="Revert to auto-detected URL"
|
||||
>
|
||||
<XIcon className="size-icon-sm" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyUrl}
|
||||
|
||||
86
frontend/src/hooks/usePreviewUrlOverride.ts
Normal file
86
frontend/src/hooks/usePreviewUrlOverride.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useScratch } from './useScratch';
|
||||
import { useDebouncedCallback } from './useDebouncedCallback';
|
||||
import {
|
||||
ScratchType,
|
||||
type PreviewUrlOverrideData,
|
||||
type ScratchPayload,
|
||||
} from 'shared/types';
|
||||
|
||||
interface UsePreviewUrlOverrideResult {
|
||||
overrideUrl: string | null;
|
||||
isLoading: boolean;
|
||||
setOverrideUrl: (url: string) => void;
|
||||
clearOverride: () => Promise<void>;
|
||||
hasOverride: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage a per-workspace preview URL override.
|
||||
* Uses the scratch system for persistence.
|
||||
*/
|
||||
export function usePreviewUrlOverride(
|
||||
workspaceId: string | undefined
|
||||
): UsePreviewUrlOverrideResult {
|
||||
const enabled = !!workspaceId;
|
||||
|
||||
const {
|
||||
scratch,
|
||||
updateScratch,
|
||||
deleteScratch,
|
||||
isLoading: isScratchLoading,
|
||||
} = useScratch(ScratchType.PREVIEW_URL_OVERRIDE, workspaceId ?? '', {
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Extract override URL from scratch data
|
||||
const payload = scratch?.payload as ScratchPayload | undefined;
|
||||
const scratchData: PreviewUrlOverrideData | undefined =
|
||||
payload?.type === 'PREVIEW_URL_OVERRIDE' ? payload.data : undefined;
|
||||
|
||||
const overrideUrl = scratchData?.url ?? null;
|
||||
const hasOverride = overrideUrl !== null && overrideUrl.trim() !== '';
|
||||
|
||||
// Debounced save to scratch
|
||||
const { debounced: debouncedSave } = useDebouncedCallback(
|
||||
async (url: string) => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
try {
|
||||
await updateScratch({
|
||||
payload: {
|
||||
type: 'PREVIEW_URL_OVERRIDE',
|
||||
data: { url },
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[usePreviewUrlOverride] Failed to save:', e);
|
||||
}
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
const setOverrideUrl = useCallback(
|
||||
(url: string) => {
|
||||
debouncedSave(url);
|
||||
},
|
||||
[debouncedSave]
|
||||
);
|
||||
|
||||
const clearOverride = useCallback(async () => {
|
||||
try {
|
||||
await deleteScratch();
|
||||
} catch (e) {
|
||||
// Ignore 404 errors when scratch doesn't exist
|
||||
console.error('[usePreviewUrlOverride] Failed to clear:', e);
|
||||
}
|
||||
}, [deleteScratch]);
|
||||
|
||||
return {
|
||||
overrideUrl,
|
||||
isLoading: isScratchLoading,
|
||||
setOverrideUrl,
|
||||
clearOverride,
|
||||
hasOverride,
|
||||
};
|
||||
}
|
||||
@@ -64,9 +64,11 @@ export type DraftWorkspaceData = { message: string, project_id: string | null, r
|
||||
|
||||
export type DraftWorkspaceRepo = { repo_id: string, target_branch: string, };
|
||||
|
||||
export type ScratchPayload = { "type": "DRAFT_TASK", "data": string } | { "type": "DRAFT_FOLLOW_UP", "data": DraftFollowUpData } | { "type": "DRAFT_WORKSPACE", "data": DraftWorkspaceData };
|
||||
export type PreviewUrlOverrideData = { url: string, };
|
||||
|
||||
export enum ScratchType { DRAFT_TASK = "DRAFT_TASK", DRAFT_FOLLOW_UP = "DRAFT_FOLLOW_UP", DRAFT_WORKSPACE = "DRAFT_WORKSPACE" }
|
||||
export type ScratchPayload = { "type": "DRAFT_TASK", "data": string } | { "type": "DRAFT_FOLLOW_UP", "data": DraftFollowUpData } | { "type": "DRAFT_WORKSPACE", "data": DraftWorkspaceData } | { "type": "PREVIEW_URL_OVERRIDE", "data": PreviewUrlOverrideData };
|
||||
|
||||
export enum ScratchType { DRAFT_TASK = "DRAFT_TASK", DRAFT_FOLLOW_UP = "DRAFT_FOLLOW_UP", DRAFT_WORKSPACE = "DRAFT_WORKSPACE", PREVIEW_URL_OVERRIDE = "PREVIEW_URL_OVERRIDE" }
|
||||
|
||||
export type Scratch = { id: string, payload: ScratchPayload, created_at: string, updated_at: string, };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user