Improve the preview functionality (vibe-kanban) (#2010)
* preview styles
* All tasks are complete. Let me provide a summary of the changes made:
## Summary
I've implemented the preview functionality improvements as requested:
### Changes Made
**1. Rust Types (`crates/db/src/models/scratch.rs`)**
- Renamed `PreviewUrlOverrideData` → `PreviewSettingsData`
- Added new fields: `screen_size`, `responsive_width`, `responsive_height`
- Updated `ScratchPayload` and `ScratchType` enum to use `PreviewSettings`
**2. New Hook (`frontend/src/hooks/usePreviewSettings.ts`)**
- Created new `usePreviewSettings` hook (replaced `usePreviewUrlOverride`)
- Manages both URL override and screen size settings
- Persists screen size via the scratch system (per-workspace)
- Exports `ScreenSize` and `ResponsiveDimensions` types
**3. PreviewBrowserContainer (`frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx`)**
- Added URL controls (input, copy, open in new tab, refresh, clear override)
- Added stop button and related handlers
- Added screen size state management
- Added responsive dimensions resize handlers
**4. PreviewBrowser View (`frontend/src/components/ui-new/views/PreviewBrowser.tsx`)**
- Added floating toolbar above the iframe with:
- URL input with action buttons
- Screen size toggle (Desktop/Mobile/Responsive icons)
- Dimensions display for responsive mode
- Stop button
- Implemented three screen modes:
- **Desktop**: Full width/height (current behavior)
- **Mobile**: 390×844px centered with shadow
- **Responsive**: Draggable resize handles on right, bottom, and corner edges
**5. PreviewControls View (`frontend/src/components/ui-new/views/PreviewControls.tsx`)**
- Simplified to show only logs
- Removed URL input row and all control buttons
- Renamed section header to "Dev Server Logs"
**6. PreviewControlsContainer (`frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx`)**
- Removed all URL-related state and handlers
- Now only manages logs display and process tab switching
All type checks pass. The screen size preference is persisted per-workspace using the existing scratch system.
* Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7
* All ESLint and TypeScript checks pass. The implementation is complete.
## Summary of Changes
**Files Modified:**
1. **`crates/db/src/models/scratch.rs`** - Renamed `PreviewUrlOverrideData` → `PreviewSettingsData` with new fields for screen size persistence
2. **`crates/server/src/bin/generate_types.rs`** - Updated type export to use new name
3. **`shared/types.ts`** - Regenerated with new types (`PreviewSettingsData`, `ScratchType.PREVIEW_SETTINGS`)
4. **`frontend/src/hooks/usePreviewSettings.ts`** (new) - Replaces `usePreviewUrlOverride.ts`, adds screen size management
5. **`frontend/src/hooks/usePreviewUrlOverride.ts`** - Deleted
6. **`frontend/src/components/ui-new/containers/PreviewBrowserContainer.tsx`** - Added URL controls, stop button, and resize state management
7. **`frontend/src/components/ui-new/views/PreviewBrowser.tsx`** - Added floating toolbar with:
- URL input with copy/open/refresh/clear buttons
- Screen size toggle (Desktop/Mobile/Responsive icons)
- Responsive resize handles
- Stop button
8. **`frontend/src/components/ui-new/containers/PreviewControlsContainer.tsx`** - Simplified to logs only
9. **`frontend/src/components/ui-new/views/PreviewControls.tsx`** - Simplified to logs only
* Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7
* All checks pass. Here's a summary of the changes made:
## Summary of Changes
### 1. Fixed infinite loop bug (`usePreviewSettings.ts`)
- Added `useMemo` to stabilize the `responsiveDimensions` object reference
- This prevents the useEffect in the container from re-triggering on every render
### 2. Updated toolbar to always show with floating style (`PreviewBrowser.tsx`)
- Toolbar is now always visible (not conditional on `isServerRunning && hasUrl`)
- Changed styling from `border-b border-border` to `rounded-md shadow-md m-base` for a floating appearance
- URL input and action buttons are disabled (with `opacity-50 cursor-not-allowed`) when no URL is available
- Start/Stop button in toolbar changes based on server state:
- Shows "Start" button when server is not running
- Shows "Stop" button when server is running
- Simplified the empty state content area to just show a message pointing to the toolbar's Start button
* All checks pass. The fix:
1. **Changed from state to refs** for `isResizing` and `resizeDirection` - these now don't trigger re-renders when they change
2. **Added `localDimensionsRef`** to capture the current dimensions for the `handleEnd` callback without adding it to the effect's dependency array
3. **Effect now only depends on `setResponsiveDimensions`** (a stable callback), so event listeners are registered once on mount and cleaned up on unmount
4. **No more race conditions** - the same event handlers persist throughout the entire drag operation
The resize should now:
- Stop immediately when you release the mouse
- Be smooth without choppiness (no effect re-runs during drag)
* The responsive resize fix is complete. Here's a summary of the changes made:
**In `PreviewBrowserContainer.tsx`:**
1. Added `isResizing` state to track when resizing is active (for UI purposes)
2. Added `setResponsiveDimensionsRef` to store the callback in a ref, preventing the resize effect from re-running when the callback identity changes
3. Changed the resize effect to use an empty dependency array (`[]`) since all external values are now accessed via refs
4. Updated `handleResizeStart` to set `isResizing(true)`
5. Updated `handleEnd` to set `isResizing(false)` and use `setResponsiveDimensionsRef.current`
6. Passed `isResizing` prop to the `PreviewBrowser` view
**In `PreviewBrowser.tsx`:**
1. Added `isResizing` prop to the interface and destructured it
2. Applied `pointer-events-none` class to the iframe when `isResizing` is true - this prevents the iframe from capturing mouse events during resize, ensuring smooth dragging
These changes fix both issues:
- **Resize not stopping**: The effect no longer re-registers listeners when `setResponsiveDimensions` changes
- **Choppy dragging**: The iframe no longer captures mouse events during resize
* All checks pass. The fix is complete. The resize now uses delta-based calculation:
1. **`startPosRef`**: Captures the mouse position when resize starts
2. **`startDimensionsRef`**: Captures the iframe dimensions when resize starts
3. **`handleMove`**: Calculates delta (current position - start position) and applies it to the starting dimensions
4. **`handleEnd`**: Clears both refs when resize ends
This approach works regardless of where the iframe is positioned in the container, fixing both:
- The mouse jump offset (no longer tries to position based on container edge)
- The 50% distance issue (delta is 1:1 with mouse movement)
* Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7
* Done. The resize should now move at 1:1 with the cursor. Since the centered element grows on both sides equally, doubling the delta ensures the edge under the cursor moves the full distance you drag.
* Done! I've added a minimal dark phone frame around the iframe when in mobile mode. The changes:
- **Mobile mode** now wraps the iframe in a phone skeleton with:
- Dark bezel (`bg-gray-900`)
- Rounded outer corners (`rounded-[3rem]`)
- Padding for bezel width (`p-3`)
- Shadow (`shadow-xl`)
- Rounded inner screen area (`rounded-[2.5rem]`)
- **Desktop and responsive modes** remain unchanged
You can test it by running `pnpm run dev:qa` and switching to mobile view in the preview browser.
* styles
* I've completed the implementation. Here's a summary of the changes:
## Summary
Created two new reusable button components and updated PreviewBrowser.tsx to standardize button styles:
### New Components
1. **`IconButton.tsx`** (`frontend/src/components/ui-new/primitives/IconButton.tsx`)
- Standalone icon button with consistent styling
- Supports `default` and `tertiary` variants
- Includes proper hover states (`bg-secondary/50`), disabled states, and transitions
2. **`IconButtonGroup.tsx`** (`frontend/src/components/ui-new/primitives/IconButtonGroup.tsx`)
- Container component for grouped icon buttons with shared border
- `IconButtonGroupItem` for individual buttons with `active` state support
- Consistent styling with border, padding, and hover/active states
### Updated PreviewBrowser.tsx
- **URL action buttons** (Clear, Copy, Open in new tab, Refresh): Now use `IconButton` component with consistent hover backgrounds
- **Screen size toggle** (Desktop, Mobile, Responsive): Now uses `IconButtonGroup` and `IconButtonGroupItem` with proper active state styling
- **Start/Stop button**: Kept using `PrimaryButton` (already consistent)
### Visual Consistency Achieved
All toolbar buttons now share:
- Consistent padding (`p-half`)
- Consistent hover states (`bg-secondary/50` background)
- Consistent active states (`bg-secondary` background) for toggles
- Consistent disabled states (`opacity-40 cursor-not-allowed`)
- Consistent icon sizing (`size-icon-sm`) with `weight="bold"`
- Consistent border radius (`rounded-sm`)
* Done. The URL action buttons (Copy, Open in new tab, Refresh) are now grouped in an `IconButtonGroup` with a shared border, matching the visual style of the screen size toggle group.
The toolbar now has three visually consistent button groups:
1. **URL input** with optional clear override button
2. **URL actions group** (Copy, Open in new tab, Refresh) - bordered group
3. **Screen size toggle group** (Desktop, Mobile, Responsive) - bordered group
4. **Start/Stop button** (PrimaryButton)
* Done. All changes have been implemented:
1. **Added `iconClassName` prop to `IconButtonGroupItem`** - Supports passing custom classes to the icon (e.g., `animate-spin` for the spinner)
2. **Moved Clear button into URL Actions group** - The Clear Override button (X icon) is now inside the `IconButtonGroup` with Copy, Open, and Refresh buttons
3. **Replaced Pause button with `IconButtonGroupItem`** - The stop/pause button now uses the same `IconButtonGroup` styling as the other toolbar buttons, with spinner animation support when stopping
The toolbar now has consistent visual styling with three button groups:
- **URL Actions**: Clear (conditional), Copy, Open in new tab, Refresh
- **Screen Size Toggle**: Desktop, Mobile, Responsive
- **Stop button**: Pause icon (when server is running)
- **Start button**: Kept as `PrimaryButton` since it has a text label
* Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7
* Done. The Start/Stop button is now a single `IconButtonGroup` with one `IconButtonGroupItem` that:
- Shows Play icon when server is stopped, Pause icon when running
- Shows Spinner icon with animation when starting or stopping
- Has the same size as all other toolbar button groups
* Cleanup script changes for workspace dd65dbe3-2e31-4897-8aa4-b2f8f7971ab7
* Done. Fixed the spinning Play icon bug by making the `iconClassName` logic match the icon selection logic exactly. Now `animate-spin` is only applied when actually showing the `SpinnerIcon`.
* Done. The entire toolbar (URL input, URL actions, screen size toggle) is now disabled when the dev server isn't running. Only the Play/Start button remains enabled so users can start the server.
* The changes compile correctly. Here's a summary of what was fixed:
## Summary
Fixed the "Fix Script" button showing at the wrong time in `PreviewBrowser.tsx`.
**Changes made:**
1. **`PreviewBrowser.tsx`** - Added `hasFailedDevServer?: boolean` prop and updated the condition on line 345 from `{handleFixDevScript && (` to `{hasFailedDevServer && handleFixDevScript && (`
2. **`PreviewBrowserContainer.tsx`** - Added failed dev server detection logic (lines 45-51) that checks for processes with `status === 'failed'` or `status === 'completed'` with non-zero exit code, and passed the `hasFailedDevServer` prop to the view
The "Fix Script" button will now only appear when the last dev server process actually failed, not when it was successful or killed.
* update styles
* All tasks completed. Here's a summary of the changes made:
## Summary
### 1. `IconButtonGroup.tsx` (lines 3, 50-65)
- Added import for `Tooltip` component
- Modified `IconButtonGroupItem` to wrap buttons with `<Tooltip>` when `title` prop is provided
- Removed native `title` attribute from button (tooltip handles it now)
### 2. `en/tasks.json` (lines 125-134)
Added new i18n keys under `preview.toolbar`:
- `clearUrlOverride`: "Clear URL override"
- `desktopView`: "Desktop view"
- `mobileView`: "Mobile view (390x844)"
- `responsiveView`: "Responsive view (resizable)"
- `startDevServer`: "Start dev server"
Added new `preview.loading` section:
- `startingServer`: "Starting dev server..."
- `waitingForServer`: "Waiting for server..."
### 3. `PreviewBrowser.tsx` (lines 160-254, 342-344)
Replaced all hardcoded strings with i18n translations using `t()`:
- URL action buttons (clear override, copy, open in tab, refresh)
- Screen size toggle buttons (desktop, mobile, responsive)
- Start/stop server button
- Loading state messages
All icon buttons now have proper accessible tooltips via the `Tooltip` component instead of native browser `title` attributes, and all strings are internationalized.
* style
* All i18n issues have been fixed. Here's a summary of the changes made:
**1. Added missing translation keys to all non-English locales (es, ja, ko, zh-Hans, zh-Hant):**
- `preview.loading.startingServer`
- `preview.loading.waitingForServer`
- `preview.toolbar.clearUrlOverride`
- `preview.toolbar.desktopView`
- `preview.toolbar.mobileView`
- `preview.toolbar.responsiveView`
- `preview.toolbar.startDevServer`
**2. Fixed hardcoded strings in `PreviewBrowser.tsx`:**
- Replaced dimension separator `x` with HTML entity `×`
- Internationalized the "no dev script" setup message with 3 new i18n keys:
- `preview.noServer.setupTitle`
- `preview.noServer.editDevScript`
- `preview.noServer.learnMore`
**3. Added the 3 new keys to all locale files (en, es, ja, ko, zh-Hans, zh-Hant)**
The `check-i18n.sh` script now passes with:
- 128 violations (down from 131 on main - a net improvement!)
- No duplicate keys
- Translation keys consistent across all locales
This commit is contained in:
committed by
GitHub
parent
34e1b688b1
commit
33419c95f7
@@ -25,10 +25,16 @@ pub struct DraftFollowUpData {
|
||||
pub variant: Option<String>,
|
||||
}
|
||||
|
||||
/// Data for a preview URL override scratch
|
||||
/// Data for preview settings scratch (URL override and screen size)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
pub struct PreviewUrlOverrideData {
|
||||
pub struct PreviewSettingsData {
|
||||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub screen_size: Option<String>,
|
||||
#[serde(default)]
|
||||
pub responsive_width: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub responsive_height: Option<i32>,
|
||||
}
|
||||
|
||||
/// Data for a draft workspace scratch (new workspace creation)
|
||||
@@ -63,7 +69,7 @@ pub enum ScratchPayload {
|
||||
DraftTask(String),
|
||||
DraftFollowUp(DraftFollowUpData),
|
||||
DraftWorkspace(DraftWorkspaceData),
|
||||
PreviewUrlOverride(PreviewUrlOverrideData),
|
||||
PreviewSettings(PreviewSettingsData),
|
||||
}
|
||||
|
||||
impl ScratchPayload {
|
||||
|
||||
@@ -39,7 +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::PreviewSettingsData::decl(),
|
||||
db::models::scratch::ScratchPayload::decl(),
|
||||
db::models::scratch::ScratchType::decl(),
|
||||
db::models::scratch::Scratch::decl(),
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { PreviewBrowser } from '../views/PreviewBrowser';
|
||||
import { usePreviewDevServer } from '../hooks/usePreviewDevServer';
|
||||
import { usePreviewUrl } from '../hooks/usePreviewUrl';
|
||||
import { usePreviewUrlOverride } from '@/hooks/usePreviewUrlOverride';
|
||||
import {
|
||||
usePreviewSettings,
|
||||
type ScreenSize,
|
||||
} from '@/hooks/usePreviewSettings';
|
||||
import { useLogStream } from '@/hooks/useLogStream';
|
||||
import { useLayoutStore } from '@/stores/useLayoutStore';
|
||||
import { useWorkspaceContext } from '@/contexts/WorkspaceContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ScriptFixerDialog } from '@/components/dialogs/scripts/ScriptFixerDialog';
|
||||
|
||||
const MIN_RESPONSIVE_WIDTH = 320;
|
||||
const MIN_RESPONSIVE_HEIGHT = 480;
|
||||
|
||||
interface PreviewBrowserContainerProps {
|
||||
attemptId?: string;
|
||||
className?: string;
|
||||
@@ -20,25 +26,218 @@ export function PreviewBrowserContainer({
|
||||
}: PreviewBrowserContainerProps) {
|
||||
const navigate = useNavigate();
|
||||
const previewRefreshKey = useLayoutStore((s) => s.previewRefreshKey);
|
||||
const triggerPreviewRefresh = useLayoutStore((s) => s.triggerPreviewRefresh);
|
||||
const { repos, workspaceId } = useWorkspaceContext();
|
||||
|
||||
const { start, isStarting, runningDevServers, devServerProcesses } =
|
||||
usePreviewDevServer(attemptId);
|
||||
const {
|
||||
start,
|
||||
stop,
|
||||
isStarting,
|
||||
isStopping,
|
||||
runningDevServers,
|
||||
devServerProcesses,
|
||||
} = usePreviewDevServer(attemptId);
|
||||
|
||||
const primaryDevServer = runningDevServers[0];
|
||||
const { logs } = useLogStream(primaryDevServer?.id ?? '');
|
||||
const urlInfo = usePreviewUrl(logs);
|
||||
|
||||
// URL override for this workspace
|
||||
const { overrideUrl, hasOverride } = usePreviewUrlOverride(workspaceId);
|
||||
// Detect failed dev server process (failed status or completed with non-zero exit code)
|
||||
const failedDevServerProcess = devServerProcesses.find(
|
||||
(p) =>
|
||||
p.status === 'failed' ||
|
||||
(p.status === 'completed' && p.exit_code !== null && p.exit_code !== 0n)
|
||||
);
|
||||
const hasFailedDevServer = Boolean(failedDevServerProcess);
|
||||
|
||||
// Preview settings (URL override and screen size)
|
||||
const {
|
||||
overrideUrl,
|
||||
hasOverride,
|
||||
setOverrideUrl,
|
||||
clearOverride,
|
||||
screenSize,
|
||||
responsiveDimensions,
|
||||
setScreenSize,
|
||||
setResponsiveDimensions,
|
||||
} = usePreviewSettings(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 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]);
|
||||
|
||||
// Responsive resize state - use refs for values that shouldn't trigger re-renders
|
||||
const [localDimensions, setLocalDimensions] = useState(responsiveDimensions);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isResizingRef = useRef(false);
|
||||
const resizeDirectionRef = useRef<'right' | 'bottom' | 'corner' | null>(null);
|
||||
const localDimensionsRef = useRef(localDimensions);
|
||||
const startPosRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const startDimensionsRef = useRef<{ width: number; height: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Store callback in ref to avoid effect re-runs when callback identity changes
|
||||
const setResponsiveDimensionsRef = useRef(setResponsiveDimensions);
|
||||
useEffect(() => {
|
||||
setResponsiveDimensionsRef.current = setResponsiveDimensions;
|
||||
}, [setResponsiveDimensions]);
|
||||
|
||||
// Keep ref in sync with state for use in event handlers
|
||||
useEffect(() => {
|
||||
localDimensionsRef.current = localDimensions;
|
||||
}, [localDimensions]);
|
||||
|
||||
// Sync local dimensions with prop when not resizing
|
||||
useEffect(() => {
|
||||
if (!isResizingRef.current) {
|
||||
setLocalDimensions(responsiveDimensions);
|
||||
}
|
||||
}, [responsiveDimensions]);
|
||||
|
||||
// Handle resize events - register listeners once on mount
|
||||
useEffect(() => {
|
||||
const handleMove = (clientX: number, clientY: number) => {
|
||||
if (
|
||||
!isResizingRef.current ||
|
||||
!startPosRef.current ||
|
||||
!startDimensionsRef.current
|
||||
)
|
||||
return;
|
||||
|
||||
const direction = resizeDirectionRef.current;
|
||||
const deltaX = clientX - startPosRef.current.x;
|
||||
const deltaY = clientY - startPosRef.current.y;
|
||||
|
||||
setLocalDimensions(() => {
|
||||
let newWidth = startDimensionsRef.current!.width;
|
||||
let newHeight = startDimensionsRef.current!.height;
|
||||
|
||||
if (direction === 'right' || direction === 'corner') {
|
||||
// Double delta to compensate for centered element (grows on both sides)
|
||||
newWidth = Math.max(
|
||||
MIN_RESPONSIVE_WIDTH,
|
||||
startDimensionsRef.current!.width + deltaX * 2
|
||||
);
|
||||
}
|
||||
|
||||
if (direction === 'bottom' || direction === 'corner') {
|
||||
// Double delta to compensate for centered element (grows on both sides)
|
||||
newHeight = Math.max(
|
||||
MIN_RESPONSIVE_HEIGHT,
|
||||
startDimensionsRef.current!.height + deltaY * 2
|
||||
);
|
||||
}
|
||||
|
||||
return { width: newWidth, height: newHeight };
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
handleMove(e.clientX, e.clientY);
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
handleMove(touch.clientX, touch.clientY);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
if (isResizingRef.current) {
|
||||
isResizingRef.current = false;
|
||||
resizeDirectionRef.current = null;
|
||||
startPosRef.current = null;
|
||||
startDimensionsRef.current = null;
|
||||
setIsResizing(false);
|
||||
setResponsiveDimensionsRef.current(localDimensionsRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleEnd);
|
||||
document.addEventListener('touchmove', handleTouchMove);
|
||||
document.addEventListener('touchend', handleEnd);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleEnd);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleEnd);
|
||||
};
|
||||
}, []); // Empty deps - mount only, uses refs for all external values
|
||||
|
||||
const handleResizeStart = useCallback(
|
||||
(direction: 'right' | 'bottom' | 'corner') =>
|
||||
(e: React.MouseEvent | React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
isResizingRef.current = true;
|
||||
resizeDirectionRef.current = direction;
|
||||
setIsResizing(true);
|
||||
|
||||
// Capture starting position and dimensions for delta-based resizing
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY;
|
||||
startPosRef.current = { x: clientX, y: clientY };
|
||||
startDimensionsRef.current = { ...localDimensionsRef.current };
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleUrlInputChange = useCallback(
|
||||
(value: string) => {
|
||||
setUrlInputValue(value);
|
||||
setOverrideUrl(value);
|
||||
},
|
||||
[setOverrideUrl]
|
||||
);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
start();
|
||||
}, [start]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
triggerPreviewRefresh();
|
||||
}, [triggerPreviewRefresh]);
|
||||
|
||||
const handleClearOverride = useCallback(async () => {
|
||||
await clearOverride();
|
||||
setUrlInputValue('');
|
||||
}, [clearOverride]);
|
||||
|
||||
const handleCopyUrl = useCallback(async () => {
|
||||
if (effectiveUrl) {
|
||||
await navigator.clipboard.writeText(effectiveUrl);
|
||||
}
|
||||
}, [effectiveUrl]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
if (effectiveUrl) {
|
||||
window.open(effectiveUrl, '_blank');
|
||||
}
|
||||
}, [effectiveUrl]);
|
||||
|
||||
const handleScreenSizeChange = useCallback(
|
||||
(size: ScreenSize) => {
|
||||
setScreenSize(size);
|
||||
},
|
||||
[setScreenSize]
|
||||
);
|
||||
|
||||
// Use previewRefreshKey from store to force iframe reload
|
||||
const iframeUrl = effectiveUrl
|
||||
? `${effectiveUrl}${effectiveUrl.includes('?') ? '&' : '?'}_refresh=${previewRefreshKey}`
|
||||
@@ -70,14 +269,32 @@ export function PreviewBrowserContainer({
|
||||
return (
|
||||
<PreviewBrowser
|
||||
url={iframeUrl}
|
||||
autoDetectedUrl={urlInfo?.url}
|
||||
urlInputValue={urlInputValue}
|
||||
urlInputRef={urlInputRef}
|
||||
isUsingOverride={hasOverride}
|
||||
onUrlInputChange={handleUrlInputChange}
|
||||
onClearOverride={handleClearOverride}
|
||||
onCopyUrl={handleCopyUrl}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onRefresh={handleRefresh}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
isStarting={isStarting}
|
||||
isStopping={isStopping}
|
||||
isServerRunning={runningDevServers.length > 0}
|
||||
screenSize={screenSize}
|
||||
localDimensions={localDimensions}
|
||||
onScreenSizeChange={handleScreenSizeChange}
|
||||
onResizeStart={handleResizeStart}
|
||||
isResizing={isResizing}
|
||||
containerRef={containerRef}
|
||||
repos={repos}
|
||||
handleEditDevScript={handleEditDevScript}
|
||||
handleFixDevScript={
|
||||
attemptId && repos.length > 0 ? handleFixDevScript : undefined
|
||||
}
|
||||
hasFailedDevServer={hasFailedDevServer}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { useCallback, useState, useEffect } 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';
|
||||
import { ScriptFixerDialog } from '@/components/dialogs/scripts/ScriptFixerDialog';
|
||||
|
||||
interface PreviewControlsContainerProps {
|
||||
attemptId?: string;
|
||||
@@ -19,18 +16,11 @@ export function PreviewControlsContainer({
|
||||
onViewProcessInPanel,
|
||||
className,
|
||||
}: PreviewControlsContainerProps) {
|
||||
const { repos, workspaceId } = useWorkspaceContext();
|
||||
const { repos } = useWorkspaceContext();
|
||||
const setLogsMode = useLayoutStore((s) => s.setLogsMode);
|
||||
const triggerPreviewRefresh = useLayoutStore((s) => s.triggerPreviewRefresh);
|
||||
|
||||
const {
|
||||
start,
|
||||
stop,
|
||||
isStarting,
|
||||
isStopping,
|
||||
runningDevServers,
|
||||
devServerProcesses,
|
||||
} = usePreviewDevServer(attemptId);
|
||||
const { isStarting, runningDevServers, devServerProcesses } =
|
||||
usePreviewDevServer(attemptId);
|
||||
|
||||
const [activeProcessId, setActiveProcessId] = useState<string | null>(null);
|
||||
|
||||
@@ -46,36 +36,6 @@ export function PreviewControlsContainer({
|
||||
|
||||
const { logs, error: logsError } = useLogStream(activeProcess?.id ?? '');
|
||||
|
||||
const primaryDevServer = runningDevServers[0];
|
||||
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;
|
||||
@@ -92,57 +52,10 @@ export function PreviewControlsContainer({
|
||||
setActiveProcessId(processId);
|
||||
}, []);
|
||||
|
||||
const handleStart = useCallback(() => {
|
||||
start();
|
||||
}, [start]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
triggerPreviewRefresh();
|
||||
}, [triggerPreviewRefresh]);
|
||||
|
||||
const handleClearOverride = useCallback(async () => {
|
||||
await clearOverride();
|
||||
}, [clearOverride]);
|
||||
|
||||
const handleCopyUrl = useCallback(async () => {
|
||||
if (effectiveUrl) {
|
||||
await navigator.clipboard.writeText(effectiveUrl);
|
||||
}
|
||||
}, [effectiveUrl]);
|
||||
|
||||
const handleOpenInNewTab = useCallback(() => {
|
||||
if (effectiveUrl) {
|
||||
window.open(effectiveUrl, '_blank');
|
||||
}
|
||||
}, [effectiveUrl]);
|
||||
|
||||
const handleFixScript = useCallback(() => {
|
||||
if (!attemptId || repos.length === 0) return;
|
||||
|
||||
// Get session ID from the latest dev server process
|
||||
const sessionId = devServerProcesses[0]?.session_id;
|
||||
|
||||
ScriptFixerDialog.show({
|
||||
scriptType: 'dev_server',
|
||||
repos,
|
||||
workspaceId: attemptId,
|
||||
sessionId,
|
||||
initialRepoId: repos.length === 1 ? repos[0].id : undefined,
|
||||
});
|
||||
}, [attemptId, repos, devServerProcesses]);
|
||||
|
||||
const hasDevScript = repos.some(
|
||||
(repo) => repo.dev_server_script && repo.dev_server_script.trim() !== ''
|
||||
);
|
||||
|
||||
// Only show "Fix Script" button when the latest dev server process failed
|
||||
const latestDevServerFailed =
|
||||
devServerProcesses.length > 0 && devServerProcesses[0]?.status === 'failed';
|
||||
|
||||
// Don't render if no repos have dev server scripts configured
|
||||
if (!hasDevScript) {
|
||||
return null;
|
||||
@@ -154,27 +67,9 @@ export function PreviewControlsContainer({
|
||||
activeProcessId={activeProcess?.id ?? null}
|
||||
logs={logs}
|
||||
logsError={logsError}
|
||||
url={effectiveUrl ?? undefined}
|
||||
autoDetectedUrl={urlInfo?.url}
|
||||
isUsingOverride={hasOverride}
|
||||
urlInputValue={urlInputValue}
|
||||
urlInputRef={urlInputRef}
|
||||
onUrlInputChange={handleUrlInputChange}
|
||||
onClearOverride={handleClearOverride}
|
||||
onViewFullLogs={handleViewFullLogs}
|
||||
onTabChange={handleTabChange}
|
||||
onStart={handleStart}
|
||||
onStop={handleStop}
|
||||
onRefresh={handleRefresh}
|
||||
onCopyUrl={handleCopyUrl}
|
||||
onOpenInNewTab={handleOpenInNewTab}
|
||||
onFixScript={
|
||||
attemptId && repos.length > 0 && latestDevServerFailed
|
||||
? handleFixScript
|
||||
: undefined
|
||||
}
|
||||
isStarting={isStarting}
|
||||
isStopping={isStopping}
|
||||
isServerRunning={runningDevServers.length > 0}
|
||||
className={className}
|
||||
/>
|
||||
|
||||
45
frontend/src/components/ui-new/primitives/IconButton.tsx
Normal file
45
frontend/src/components/ui-new/primitives/IconButton.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Icon } from '@phosphor-icons/react';
|
||||
|
||||
interface IconButtonProps {
|
||||
icon: Icon;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
variant?: 'default' | 'tertiary';
|
||||
'aria-label': string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
icon: IconComponent,
|
||||
onClick,
|
||||
disabled,
|
||||
variant = 'default',
|
||||
'aria-label': ariaLabel,
|
||||
title,
|
||||
className,
|
||||
}: IconButtonProps) {
|
||||
const variantStyles = disabled
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: variant === 'default'
|
||||
? 'text-low hover:text-normal hover:bg-secondary/50'
|
||||
: 'bg-panel hover:bg-secondary text-normal';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex items-center justify-center p-half rounded-sm transition-colors',
|
||||
variantStyles,
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
>
|
||||
<IconComponent className="size-icon-sm" weight="bold" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Icon } from '@phosphor-icons/react';
|
||||
import { Tooltip } from './Tooltip';
|
||||
|
||||
interface IconButtonGroupProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconButtonGroup({ children, className }: IconButtonGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center rounded-sm border border-border overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IconButtonGroupItemProps {
|
||||
icon: Icon;
|
||||
iconClassName?: string;
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
active?: boolean;
|
||||
'aria-label': string;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconButtonGroupItem({
|
||||
icon: IconComponent,
|
||||
iconClassName,
|
||||
onClick,
|
||||
disabled,
|
||||
active,
|
||||
'aria-label': ariaLabel,
|
||||
title,
|
||||
className,
|
||||
}: IconButtonGroupItemProps) {
|
||||
const stateStyles = disabled
|
||||
? 'opacity-40 cursor-not-allowed'
|
||||
: active
|
||||
? 'bg-secondary text-normal'
|
||||
: 'text-low hover:text-normal hover:bg-secondary/50';
|
||||
|
||||
const button = (
|
||||
<button
|
||||
type="button"
|
||||
className={cn('p-half transition-colors', stateStyles, className)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<IconComponent
|
||||
className={cn('size-icon-sm', iconClassName)}
|
||||
weight="bold"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
return title ? <Tooltip content={title}>{button}</Tooltip> : button;
|
||||
}
|
||||
@@ -1,28 +1,90 @@
|
||||
import { PlayIcon, SpinnerIcon, WrenchIcon } from '@phosphor-icons/react';
|
||||
import type { RefObject } from 'react';
|
||||
import {
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
WrenchIcon,
|
||||
ArrowSquareOutIcon,
|
||||
ArrowClockwiseIcon,
|
||||
CopyIcon,
|
||||
XIcon,
|
||||
MonitorIcon,
|
||||
DeviceMobileIcon,
|
||||
ArrowsOutCardinalIcon,
|
||||
PauseIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { PrimaryButton } from '../primitives/PrimaryButton';
|
||||
import { Repo } from 'shared/types';
|
||||
import {
|
||||
IconButtonGroup,
|
||||
IconButtonGroupItem,
|
||||
} from '../primitives/IconButtonGroup';
|
||||
import type { Repo } from 'shared/types';
|
||||
import type {
|
||||
ScreenSize,
|
||||
ResponsiveDimensions,
|
||||
} from '@/hooks/usePreviewSettings';
|
||||
|
||||
const MOBILE_WIDTH = 390;
|
||||
const MOBILE_HEIGHT = 844;
|
||||
|
||||
interface PreviewBrowserProps {
|
||||
url?: string;
|
||||
autoDetectedUrl?: string;
|
||||
urlInputValue: string;
|
||||
urlInputRef: RefObject<HTMLInputElement>;
|
||||
isUsingOverride?: boolean;
|
||||
onUrlInputChange: (value: string) => void;
|
||||
onClearOverride?: () => void;
|
||||
onCopyUrl: () => void;
|
||||
onOpenInNewTab: () => void;
|
||||
onRefresh: () => void;
|
||||
onStart: () => void;
|
||||
onStop: () => void;
|
||||
isStarting: boolean;
|
||||
isStopping: boolean;
|
||||
isServerRunning: boolean;
|
||||
screenSize: ScreenSize;
|
||||
localDimensions: ResponsiveDimensions;
|
||||
onScreenSizeChange: (size: ScreenSize) => void;
|
||||
onResizeStart: (
|
||||
direction: 'right' | 'bottom' | 'corner'
|
||||
) => (e: React.MouseEvent | React.TouchEvent) => void;
|
||||
isResizing: boolean;
|
||||
containerRef: RefObject<HTMLDivElement>;
|
||||
repos: Repo[];
|
||||
handleEditDevScript: () => void;
|
||||
handleFixDevScript?: () => void;
|
||||
hasFailedDevServer?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PreviewBrowser({
|
||||
url,
|
||||
autoDetectedUrl,
|
||||
urlInputValue,
|
||||
urlInputRef,
|
||||
isUsingOverride,
|
||||
onUrlInputChange,
|
||||
onClearOverride,
|
||||
onCopyUrl,
|
||||
onOpenInNewTab,
|
||||
onRefresh,
|
||||
onStart,
|
||||
onStop,
|
||||
isStarting,
|
||||
isStopping,
|
||||
isServerRunning,
|
||||
screenSize,
|
||||
localDimensions,
|
||||
onScreenSizeChange,
|
||||
onResizeStart,
|
||||
isResizing,
|
||||
containerRef,
|
||||
repos,
|
||||
handleEditDevScript,
|
||||
handleFixDevScript,
|
||||
hasFailedDevServer,
|
||||
className,
|
||||
}: PreviewBrowserProps) {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
@@ -33,23 +95,244 @@ export function PreviewBrowser({
|
||||
(repo) => repo.dev_server_script && repo.dev_server_script.trim() !== ''
|
||||
);
|
||||
|
||||
const getIframeContainerStyle = (): React.CSSProperties => {
|
||||
switch (screenSize) {
|
||||
case 'mobile':
|
||||
return {
|
||||
width: MOBILE_WIDTH,
|
||||
height: MOBILE_HEIGHT,
|
||||
};
|
||||
case 'responsive':
|
||||
return {
|
||||
width: localDimensions.width,
|
||||
height: localDimensions.height,
|
||||
};
|
||||
case 'desktop':
|
||||
default:
|
||||
return {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full h-full bg-secondary flex flex-col overflow-hidden',
|
||||
'bg-brand/20 w-full h-full flex flex-col overflow-hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Floating Toolbar */}
|
||||
<div className="p-double">
|
||||
<div className="backdrop-blur-sm bg-primary/80 border border-brand/20 flex items-center gap-base p-base rounded-md shadow-md shrink-0">
|
||||
{/* URL Input */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-half rounded-sm px-base py-half flex-1 min-w-0',
|
||||
!isServerRunning && 'opacity-50'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={urlInputRef}
|
||||
type="text"
|
||||
value={urlInputValue}
|
||||
onChange={(e) => onUrlInputChange(e.target.value)}
|
||||
placeholder={autoDetectedUrl ?? 'Enter URL...'}
|
||||
disabled={!isServerRunning}
|
||||
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',
|
||||
!isServerRunning && 'cursor-not-allowed'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* URL Actions */}
|
||||
<IconButtonGroup>
|
||||
{isUsingOverride && (
|
||||
<IconButtonGroupItem
|
||||
icon={XIcon}
|
||||
onClick={onClearOverride}
|
||||
disabled={!isServerRunning}
|
||||
aria-label={t('preview.toolbar.clearUrlOverride')}
|
||||
title={t('preview.toolbar.resetUrl')}
|
||||
/>
|
||||
)}
|
||||
<IconButtonGroupItem
|
||||
icon={CopyIcon}
|
||||
onClick={onCopyUrl}
|
||||
disabled={!isServerRunning}
|
||||
aria-label={t('preview.toolbar.copyUrl')}
|
||||
title={t('preview.toolbar.copyUrl')}
|
||||
/>
|
||||
<IconButtonGroupItem
|
||||
icon={ArrowSquareOutIcon}
|
||||
onClick={onOpenInNewTab}
|
||||
disabled={!isServerRunning}
|
||||
aria-label={t('preview.toolbar.openInTab')}
|
||||
title={t('preview.toolbar.openInTab')}
|
||||
/>
|
||||
<IconButtonGroupItem
|
||||
icon={ArrowClockwiseIcon}
|
||||
onClick={onRefresh}
|
||||
disabled={!isServerRunning}
|
||||
aria-label={t('preview.toolbar.refresh')}
|
||||
title={t('preview.toolbar.refresh')}
|
||||
/>
|
||||
</IconButtonGroup>
|
||||
|
||||
{/* Screen Size Toggle */}
|
||||
<IconButtonGroup>
|
||||
<IconButtonGroupItem
|
||||
icon={MonitorIcon}
|
||||
onClick={() => onScreenSizeChange('desktop')}
|
||||
active={screenSize === 'desktop'}
|
||||
disabled={!isServerRunning}
|
||||
aria-label={t('preview.toolbar.desktopView')}
|
||||
title={t('preview.toolbar.desktopView')}
|
||||
/>
|
||||
<IconButtonGroupItem
|
||||
icon={DeviceMobileIcon}
|
||||
onClick={() => onScreenSizeChange('mobile')}
|
||||
active={screenSize === 'mobile'}
|
||||
disabled={!isServerRunning}
|
||||
aria-label={t('preview.toolbar.mobileView')}
|
||||
title={t('preview.toolbar.mobileView')}
|
||||
/>
|
||||
<IconButtonGroupItem
|
||||
icon={ArrowsOutCardinalIcon}
|
||||
onClick={() => onScreenSizeChange('responsive')}
|
||||
active={screenSize === 'responsive'}
|
||||
disabled={!isServerRunning}
|
||||
aria-label={t('preview.toolbar.responsiveView')}
|
||||
title={t('preview.toolbar.responsiveView')}
|
||||
/>
|
||||
</IconButtonGroup>
|
||||
|
||||
{/* Dimensions display for responsive mode */}
|
||||
{screenSize === 'responsive' && (
|
||||
<span className="text-xs text-low font-mono whitespace-nowrap">
|
||||
{Math.round(localDimensions.width)} ×{' '}
|
||||
{Math.round(localDimensions.height)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Start/Stop Button */}
|
||||
<IconButtonGroup>
|
||||
<IconButtonGroupItem
|
||||
icon={
|
||||
isServerRunning
|
||||
? isStopping
|
||||
? SpinnerIcon
|
||||
: PauseIcon
|
||||
: isStarting
|
||||
? SpinnerIcon
|
||||
: PlayIcon
|
||||
}
|
||||
iconClassName={
|
||||
(isServerRunning && isStopping) ||
|
||||
(!isServerRunning && isStarting)
|
||||
? 'animate-spin'
|
||||
: undefined
|
||||
}
|
||||
onClick={isServerRunning ? onStop : onStart}
|
||||
disabled={
|
||||
isServerRunning ? isStopping : isStarting || !hasDevScript
|
||||
}
|
||||
aria-label={
|
||||
isServerRunning
|
||||
? t('preview.toolbar.stopDevServer')
|
||||
: t('preview.toolbar.startDevServer')
|
||||
}
|
||||
title={
|
||||
isServerRunning
|
||||
? t('preview.toolbar.stopDevServer')
|
||||
: t('preview.toolbar.startDevServer')
|
||||
}
|
||||
/>
|
||||
</IconButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 min-h-0 relative overflow-auto px-double pb-double"
|
||||
>
|
||||
{showIframe ? (
|
||||
<iframe
|
||||
src={url}
|
||||
title={t('preview.browser.title')}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'h-full',
|
||||
screenSize === 'desktop'
|
||||
? ''
|
||||
: 'flex items-center justify-center p-double'
|
||||
)}
|
||||
>
|
||||
{screenSize === 'mobile' ? (
|
||||
// Phone frame for mobile mode
|
||||
<div className="bg-primary rounded-[2rem] p-3 shadow-xl">
|
||||
<div
|
||||
className="rounded-[1.5rem] overflow-hidden"
|
||||
style={{ width: MOBILE_WIDTH, height: MOBILE_HEIGHT }}
|
||||
>
|
||||
<iframe
|
||||
src={url}
|
||||
title={t('preview.browser.title')}
|
||||
className="w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Desktop and responsive modes
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-sm border overflow-hidden relative',
|
||||
screenSize === 'responsive' && 'shadow-lg'
|
||||
)}
|
||||
style={getIframeContainerStyle()}
|
||||
>
|
||||
<iframe
|
||||
src={url}
|
||||
title={t('preview.browser.title')}
|
||||
className={cn(
|
||||
'w-full h-full border-0',
|
||||
isResizing && 'pointer-events-none'
|
||||
)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
|
||||
{/* Resize handles for responsive mode */}
|
||||
{screenSize === 'responsive' && (
|
||||
<>
|
||||
{/* Right edge handle */}
|
||||
<div
|
||||
className="absolute top-0 right-0 w-2 h-full cursor-ew-resize hover:bg-brand/30 transition-colors"
|
||||
onMouseDown={onResizeStart('right')}
|
||||
onTouchStart={onResizeStart('right')}
|
||||
/>
|
||||
{/* Bottom edge handle */}
|
||||
<div
|
||||
className="absolute bottom-0 left-0 w-full h-2 cursor-ns-resize hover:bg-brand/30 transition-colors"
|
||||
onMouseDown={onResizeStart('bottom')}
|
||||
onTouchStart={onResizeStart('bottom')}
|
||||
/>
|
||||
{/* Corner handle */}
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-4 h-4 cursor-nwse-resize hover:bg-brand/30 transition-colors"
|
||||
onMouseDown={onResizeStart('corner')}
|
||||
onTouchStart={onResizeStart('corner')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-base text-low">
|
||||
{isLoading ? (
|
||||
@@ -57,47 +340,40 @@ export function PreviewBrowser({
|
||||
<SpinnerIcon className="size-icon-lg animate-spin text-brand" />
|
||||
<p className="text-sm">
|
||||
{isStarting
|
||||
? 'Starting dev server...'
|
||||
: 'Waiting for server...'}
|
||||
? t('preview.loading.startingServer')
|
||||
: t('preview.loading.waitingForServer')}
|
||||
</p>
|
||||
</>
|
||||
) : hasDevScript ? (
|
||||
<>
|
||||
<p className="text-sm">{t('preview.noServer.title')}</p>
|
||||
<div className="flex gap-base">
|
||||
<p>{t('preview.noServer.title')}</p>
|
||||
{hasFailedDevServer && handleFixDevScript ? (
|
||||
<PrimaryButton
|
||||
value={t('preview.browser.startButton')}
|
||||
variant="tertiary"
|
||||
value={t('scriptFixer.fixScript')}
|
||||
actionIcon={WrenchIcon}
|
||||
onClick={handleFixDevScript}
|
||||
/>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
value={t('attempt.actions.startDevServer')}
|
||||
actionIcon={PlayIcon}
|
||||
onClick={onStart}
|
||||
disabled={isStarting}
|
||||
/>
|
||||
{handleFixDevScript && (
|
||||
<PrimaryButton
|
||||
variant="tertiary"
|
||||
value={t('scriptFixer.fixScript')}
|
||||
actionIcon={WrenchIcon}
|
||||
onClick={handleFixDevScript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-double p-double max-w-md">
|
||||
<div className="flex flex-col gap-base">
|
||||
<p className="text-xl text-high max-w-xs">
|
||||
You must set up a dev server script to use the preview
|
||||
feature
|
||||
</p>
|
||||
<p>
|
||||
Vibe Kanban can run dev servers to help you test your
|
||||
changes. You can set up a dev server script in the
|
||||
repository section of the settings page.
|
||||
{t('preview.noServer.setupTitle')}
|
||||
</p>
|
||||
<p>{t('preview.noServer.setupPrompt')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-base">
|
||||
<div>
|
||||
<PrimaryButton
|
||||
value="Edit Dev Server Script"
|
||||
value={t('preview.noServer.editDevScript')}
|
||||
onClick={handleEditDevScript}
|
||||
/>
|
||||
</div>
|
||||
@@ -107,7 +383,7 @@ export function PreviewBrowser({
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand hover:text-brand-hover underline"
|
||||
>
|
||||
Learn more about testing applications
|
||||
{t('preview.noServer.learnMore')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import type { RefObject } from 'react';
|
||||
import {
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
ArrowSquareOutIcon,
|
||||
ArrowClockwiseIcon,
|
||||
SpinnerIcon,
|
||||
CopyIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import { ArrowSquareOutIcon, SpinnerIcon } from '@phosphor-icons/react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CollapsibleSectionHeader } from '../primitives/CollapsibleSectionHeader';
|
||||
import { PrimaryButton } from '../primitives/PrimaryButton';
|
||||
import { VirtualizedProcessLogs } from '../VirtualizedProcessLogs';
|
||||
import { PERSIST_KEYS } from '@/stores/useUiPreferencesStore';
|
||||
import { getDevServerWorkingDir } from '@/lib/devServerUtils';
|
||||
@@ -25,23 +14,9 @@ interface PreviewControlsProps {
|
||||
activeProcessId: string | null;
|
||||
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;
|
||||
onStop: () => void;
|
||||
onRefresh: () => void;
|
||||
onCopyUrl: () => void;
|
||||
onOpenInNewTab: () => void;
|
||||
onFixScript?: () => void;
|
||||
isStarting: boolean;
|
||||
isStopping: boolean;
|
||||
isServerRunning: boolean;
|
||||
className?: string;
|
||||
}
|
||||
@@ -51,28 +26,14 @@ export function PreviewControls({
|
||||
activeProcessId,
|
||||
logs,
|
||||
logsError,
|
||||
url,
|
||||
autoDetectedUrl,
|
||||
isUsingOverride,
|
||||
urlInputValue,
|
||||
urlInputRef,
|
||||
onUrlInputChange,
|
||||
onClearOverride,
|
||||
onViewFullLogs,
|
||||
onTabChange,
|
||||
onStart,
|
||||
onStop,
|
||||
onRefresh,
|
||||
onCopyUrl,
|
||||
onOpenInNewTab,
|
||||
onFixScript,
|
||||
isStarting,
|
||||
isStopping,
|
||||
isServerRunning,
|
||||
className,
|
||||
}: PreviewControlsProps) {
|
||||
const { t } = useTranslation(['tasks', 'common']);
|
||||
const isLoading = isStarting || (isServerRunning && !url);
|
||||
const isLoading = isStarting || isServerRunning;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -82,92 +43,12 @@ export function PreviewControls({
|
||||
)}
|
||||
>
|
||||
<CollapsibleSectionHeader
|
||||
title="Dev Server"
|
||||
title="Dev Server Logs"
|
||||
persistKey={PERSIST_KEYS.devServerSection}
|
||||
contentClassName="flex flex-col flex-1 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-half p-base">
|
||||
{(url || autoDetectedUrl) && (
|
||||
<div className="flex items-center gap-half bg-panel rounded-sm px-base py-half flex-1 min-w-0">
|
||||
<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}
|
||||
className="text-low hover:text-normal"
|
||||
aria-label="Copy URL"
|
||||
>
|
||||
<CopyIcon className="size-icon-sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenInNewTab}
|
||||
className="text-low hover:text-normal"
|
||||
aria-label="Open in new tab"
|
||||
>
|
||||
<ArrowSquareOutIcon className="size-icon-sm" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="text-low hover:text-normal"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<ArrowClockwiseIcon className="size-icon-sm" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isServerRunning ? (
|
||||
<PrimaryButton
|
||||
variant="tertiary"
|
||||
value={t('preview.browser.stopButton')}
|
||||
actionIcon={isStopping ? 'spinner' : StopIcon}
|
||||
onClick={onStop}
|
||||
disabled={isStopping}
|
||||
/>
|
||||
) : (
|
||||
<PrimaryButton
|
||||
value={t('preview.browser.startingButton')}
|
||||
actionIcon={isStarting ? 'spinner' : PlayIcon}
|
||||
onClick={onStart}
|
||||
disabled={isStarting}
|
||||
/>
|
||||
)}
|
||||
{onFixScript && (
|
||||
<PrimaryButton
|
||||
variant="tertiary"
|
||||
value={t('scriptFixer.fixScript')}
|
||||
actionIcon={WrenchIcon}
|
||||
onClick={onFixScript}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="flex items-center justify-between px-base pb-half">
|
||||
<div className="flex items-center justify-between px-base py-half">
|
||||
<span className="text-xs font-medium text-low">
|
||||
{t('preview.logs.label')}
|
||||
</span>
|
||||
|
||||
169
frontend/src/hooks/usePreviewSettings.ts
Normal file
169
frontend/src/hooks/usePreviewSettings.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useScratch } from './useScratch';
|
||||
import { useDebouncedCallback } from './useDebouncedCallback';
|
||||
import {
|
||||
ScratchType,
|
||||
type PreviewSettingsData,
|
||||
type ScratchPayload,
|
||||
} from 'shared/types';
|
||||
|
||||
export type ScreenSize = 'desktop' | 'mobile' | 'responsive';
|
||||
|
||||
export interface ResponsiveDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface UsePreviewSettingsResult {
|
||||
// URL override
|
||||
overrideUrl: string | null;
|
||||
hasOverride: boolean;
|
||||
setOverrideUrl: (url: string) => void;
|
||||
clearOverride: () => Promise<void>;
|
||||
|
||||
// Screen size
|
||||
screenSize: ScreenSize;
|
||||
responsiveDimensions: ResponsiveDimensions;
|
||||
setScreenSize: (size: ScreenSize) => void;
|
||||
setResponsiveDimensions: (dimensions: ResponsiveDimensions) => void;
|
||||
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RESPONSIVE_DIMENSIONS: ResponsiveDimensions = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to manage per-workspace preview settings (URL override and screen size).
|
||||
* Uses the scratch system for persistence.
|
||||
*/
|
||||
export function usePreviewSettings(
|
||||
workspaceId: string | undefined
|
||||
): UsePreviewSettingsResult {
|
||||
const enabled = !!workspaceId;
|
||||
|
||||
const {
|
||||
scratch,
|
||||
updateScratch,
|
||||
deleteScratch,
|
||||
isLoading: isScratchLoading,
|
||||
} = useScratch(ScratchType.PREVIEW_SETTINGS, workspaceId ?? '', {
|
||||
enabled,
|
||||
});
|
||||
|
||||
// Extract settings from scratch data
|
||||
const payload = scratch?.payload as ScratchPayload | undefined;
|
||||
const scratchData: PreviewSettingsData | undefined =
|
||||
payload?.type === 'PREVIEW_SETTINGS' ? payload.data : undefined;
|
||||
|
||||
const overrideUrl = scratchData?.url ?? null;
|
||||
const hasOverride = overrideUrl !== null && overrideUrl.trim() !== '';
|
||||
|
||||
const screenSize: ScreenSize =
|
||||
(scratchData?.screen_size as ScreenSize) ?? 'desktop';
|
||||
const responsiveDimensions: ResponsiveDimensions = useMemo(
|
||||
() => ({
|
||||
width:
|
||||
scratchData?.responsive_width ?? DEFAULT_RESPONSIVE_DIMENSIONS.width,
|
||||
height:
|
||||
scratchData?.responsive_height ?? DEFAULT_RESPONSIVE_DIMENSIONS.height,
|
||||
}),
|
||||
[scratchData?.responsive_width, scratchData?.responsive_height]
|
||||
);
|
||||
|
||||
// Helper to save settings
|
||||
const saveSettings = useCallback(
|
||||
async (updates: Partial<PreviewSettingsData>) => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
try {
|
||||
await updateScratch({
|
||||
payload: {
|
||||
type: 'PREVIEW_SETTINGS',
|
||||
data: {
|
||||
url: updates.url ?? overrideUrl ?? '',
|
||||
screen_size: updates.screen_size ?? screenSize,
|
||||
responsive_width:
|
||||
updates.responsive_width ?? responsiveDimensions.width,
|
||||
responsive_height:
|
||||
updates.responsive_height ?? responsiveDimensions.height,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[usePreviewSettings] Failed to save:', e);
|
||||
}
|
||||
},
|
||||
[
|
||||
workspaceId,
|
||||
updateScratch,
|
||||
overrideUrl,
|
||||
screenSize,
|
||||
responsiveDimensions.width,
|
||||
responsiveDimensions.height,
|
||||
]
|
||||
);
|
||||
|
||||
// Debounced save for URL changes (frequent typing)
|
||||
const { debounced: debouncedSaveUrl } = useDebouncedCallback(
|
||||
async (url: string) => {
|
||||
await saveSettings({ url });
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
// Debounced save for responsive dimensions (frequent dragging)
|
||||
const { debounced: debouncedSaveDimensions } = useDebouncedCallback(
|
||||
async (dimensions: ResponsiveDimensions) => {
|
||||
await saveSettings({
|
||||
responsive_width: dimensions.width,
|
||||
responsive_height: dimensions.height,
|
||||
});
|
||||
},
|
||||
300
|
||||
);
|
||||
|
||||
const setOverrideUrl = useCallback(
|
||||
(url: string) => {
|
||||
debouncedSaveUrl(url);
|
||||
},
|
||||
[debouncedSaveUrl]
|
||||
);
|
||||
|
||||
const setScreenSize = useCallback(
|
||||
(size: ScreenSize) => {
|
||||
saveSettings({ screen_size: size });
|
||||
},
|
||||
[saveSettings]
|
||||
);
|
||||
|
||||
const setResponsiveDimensions = useCallback(
|
||||
(dimensions: ResponsiveDimensions) => {
|
||||
debouncedSaveDimensions(dimensions);
|
||||
},
|
||||
[debouncedSaveDimensions]
|
||||
);
|
||||
|
||||
const clearOverride = useCallback(async () => {
|
||||
try {
|
||||
await deleteScratch();
|
||||
} catch (e) {
|
||||
// Ignore 404 errors when scratch doesn't exist
|
||||
console.error('[usePreviewSettings] Failed to clear:', e);
|
||||
}
|
||||
}, [deleteScratch]);
|
||||
|
||||
return {
|
||||
overrideUrl,
|
||||
hasOverride,
|
||||
setOverrideUrl,
|
||||
clearOverride,
|
||||
screenSize,
|
||||
responsiveDimensions,
|
||||
setScreenSize,
|
||||
setResponsiveDimensions,
|
||||
isLoading: isScratchLoading,
|
||||
};
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -85,13 +85,16 @@
|
||||
"noServer": {
|
||||
"title": "No dev server running",
|
||||
"startPrompt": "Please start a dev server to see the preview",
|
||||
"setupTitle": "You must set up a dev server script to use the preview feature",
|
||||
"setupPrompt": "To use the live preview and click-to-edit, please add a dev server script to this project.",
|
||||
"companionPrompt": "For click-to-edit functionality, add the browser companion to your project.",
|
||||
"companionLink": "View installation guide",
|
||||
"startButton": "Start Dev Server",
|
||||
"configureButton": "Configure",
|
||||
"stopAndEditButton": "Stop Dev Server & Resolve Issues",
|
||||
"fixScript": "Fix Dev Script"
|
||||
"fixScript": "Fix Dev Script",
|
||||
"editDevScript": "Edit Dev Server Script",
|
||||
"learnMore": "Learn more about testing applications"
|
||||
},
|
||||
"devScript": {
|
||||
"saveAndStart": "Save & Start",
|
||||
@@ -121,7 +124,16 @@
|
||||
"openInTab": "Open in new tab",
|
||||
"stopDevServer": "Stop dev server",
|
||||
"clickToEdit": "Click to edit URL",
|
||||
"resetUrl": "Reset to detected URL"
|
||||
"resetUrl": "Reset to detected URL",
|
||||
"clearUrlOverride": "Clear URL override",
|
||||
"desktopView": "Desktop view",
|
||||
"mobileView": "Mobile view (390x844)",
|
||||
"responsiveView": "Responsive view (resizable)",
|
||||
"startDevServer": "Start dev server"
|
||||
},
|
||||
"loading": {
|
||||
"startingServer": "Starting dev server...",
|
||||
"waitingForServer": "Waiting for server..."
|
||||
},
|
||||
"browser": {
|
||||
"title": "Dev Server Preview",
|
||||
|
||||
@@ -381,12 +381,15 @@
|
||||
"companionLink": "Ver guía de instalación",
|
||||
"companionPrompt": "Para la funcionalidad de clic y editar, agrega el complemento del navegador a tu proyecto.",
|
||||
"configureButton": "Configurar",
|
||||
"setupTitle": "Debes configurar un script de servidor de desarrollo para usar la función de vista previa",
|
||||
"setupPrompt": "Para usar la vista previa en vivo y la función de hacer clic y editar, por favor agrega un script de servidor de desarrollo a este proyecto.",
|
||||
"startButton": "Iniciar Servidor de Desarrollo",
|
||||
"startPrompt": "Por favor inicia un servidor de desarrollo para ver la vista previa",
|
||||
"stopAndEditButton": "Detener Servidor de Desarrollo y Resolver Problemas",
|
||||
"title": "No hay servidor de desarrollo en ejecución",
|
||||
"fixScript": "Corregir Script de Desarrollo"
|
||||
"fixScript": "Corregir Script de Desarrollo",
|
||||
"editDevScript": "Editar Script de Servidor de Desarrollo",
|
||||
"learnMore": "Más información sobre cómo probar aplicaciones"
|
||||
},
|
||||
"selectAttempt": "Select an attempt to see preview",
|
||||
"title": "Preview",
|
||||
@@ -396,7 +399,16 @@
|
||||
"refresh": "Actualizar vista previa",
|
||||
"stopDevServer": "Detener servidor de desarrollo",
|
||||
"clickToEdit": "Clic para editar URL",
|
||||
"resetUrl": "Restablecer a URL detectada"
|
||||
"resetUrl": "Restablecer a URL detectada",
|
||||
"clearUrlOverride": "Limpiar anulación de URL",
|
||||
"desktopView": "Vista de escritorio",
|
||||
"mobileView": "Vista móvil (390x844)",
|
||||
"responsiveView": "Vista adaptable (redimensionable)",
|
||||
"startDevServer": "Iniciar servidor de desarrollo"
|
||||
},
|
||||
"loading": {
|
||||
"startingServer": "Iniciando servidor de desarrollo...",
|
||||
"waitingForServer": "Esperando servidor..."
|
||||
},
|
||||
"troubleAlert": {
|
||||
"item1": "¿Se inició correctamente el servidor de desarrollo? Puede haber un error que necesites resolver, o quizás sea necesario instalar dependencias.",
|
||||
|
||||
@@ -381,12 +381,15 @@
|
||||
"companionLink": "インストールガイドを表示",
|
||||
"companionPrompt": "クリック編集機能のために、プロジェクトにブラウザコンパニオンを追加してください。",
|
||||
"configureButton": "設定",
|
||||
"setupTitle": "プレビュー機能を使用するには、開発サーバースクリプトを設定する必要があります",
|
||||
"setupPrompt": "ライブプレビューとクリックして編集機能を使用するには、このプロジェクトに開発サーバースクリプトを追加してください。",
|
||||
"startButton": "開発サーバーを開始",
|
||||
"startPrompt": "プレビューを表示するには開発サーバーを起動してください",
|
||||
"stopAndEditButton": "開発サーバーを停止して問題を解決",
|
||||
"title": "開発サーバーが実行されていません",
|
||||
"fixScript": "開発スクリプトを修正"
|
||||
"fixScript": "開発スクリプトを修正",
|
||||
"editDevScript": "開発サーバースクリプトを編集",
|
||||
"learnMore": "アプリケーションのテストについてもっと詳しく"
|
||||
},
|
||||
"selectAttempt": "Select an attempt to see preview",
|
||||
"title": "Preview",
|
||||
@@ -396,7 +399,16 @@
|
||||
"refresh": "プレビューを更新",
|
||||
"stopDevServer": "開発サーバーを停止",
|
||||
"clickToEdit": "クリックしてURLを編集",
|
||||
"resetUrl": "検出されたURLにリセット"
|
||||
"resetUrl": "検出されたURLにリセット",
|
||||
"clearUrlOverride": "URL オーバーライドをクリア",
|
||||
"desktopView": "デスクトップ表示",
|
||||
"mobileView": "モバイル表示 (390x844)",
|
||||
"responsiveView": "レスポンシブ表示(リサイズ可能)",
|
||||
"startDevServer": "開発サーバーを開始"
|
||||
},
|
||||
"loading": {
|
||||
"startingServer": "開発サーバーを起動中...",
|
||||
"waitingForServer": "サーバーを待機中..."
|
||||
},
|
||||
"troubleAlert": {
|
||||
"item1": "開発サーバーが正常に起動しましたか?解決すべきバグがあるか、依存関係のインストールが必要な可能性があります。",
|
||||
|
||||
@@ -373,12 +373,15 @@
|
||||
"companionLink": "설치 가이드 보기",
|
||||
"companionPrompt": "클릭하여 편집 기능을 사용하려면 프로젝트에 브라우저 컴패니언을 추가하세요.",
|
||||
"configureButton": "구성",
|
||||
"setupTitle": "미리보기 기능을 사용하려면 개발 서버 스크립트를 설정해야 합니다",
|
||||
"setupPrompt": "라이브 미리보기 및 클릭하여 편집을 사용하려면 이 프로젝트에 개발 서버 스크립트를 추가하세요.",
|
||||
"startButton": "개발 서버 시작",
|
||||
"startPrompt": "미리보기를 보려면 개발 서버를 시작하세요",
|
||||
"stopAndEditButton": "개발 서버 중지 및 문제 해결",
|
||||
"title": "실행 중인 개발 서버 없음",
|
||||
"fixScript": "개발 스크립트 수정"
|
||||
"fixScript": "개발 스크립트 수정",
|
||||
"editDevScript": "개발 서버 스크립트 편집",
|
||||
"learnMore": "애플리케이션 테스트에 대해 자세히 알아보기"
|
||||
},
|
||||
"selectAttempt": "Select an attempt to see preview",
|
||||
"title": "Preview",
|
||||
@@ -388,7 +391,16 @@
|
||||
"refresh": "미리보기 새로고침",
|
||||
"stopDevServer": "개발 서버 중지",
|
||||
"clickToEdit": "URL 편집하려면 클릭",
|
||||
"resetUrl": "감지된 URL로 재설정"
|
||||
"resetUrl": "감지된 URL로 재설정",
|
||||
"clearUrlOverride": "URL 오버라이드 지우기",
|
||||
"desktopView": "데스크탑 보기",
|
||||
"mobileView": "모바일 보기 (390x844)",
|
||||
"responsiveView": "반응형 보기 (크기 조절 가능)",
|
||||
"startDevServer": "개발 서버 시작"
|
||||
},
|
||||
"loading": {
|
||||
"startingServer": "개발 서버 시작 중...",
|
||||
"waitingForServer": "서버 대기 중..."
|
||||
},
|
||||
"troubleAlert": {
|
||||
"item1": "개발 서버가 성공적으로 시작되었나요? 해결해야 할 버그가 있거나 종속성을 설치해야 할 수 있습니다.",
|
||||
|
||||
@@ -98,13 +98,16 @@
|
||||
"noServer": {
|
||||
"title": "没有运行开发服务器",
|
||||
"startPrompt": "请启动开发服务器以查看预览",
|
||||
"setupTitle": "必须设置开发服务器脚本才能使用预览功能",
|
||||
"setupPrompt": "要使用实时预览和点击编辑,请为此项目添加开发服务器脚本。",
|
||||
"companionPrompt": "要使用点击编辑功能,请将浏览器伴侣添加到您的项目。",
|
||||
"companionLink": "查看安装指南",
|
||||
"startButton": "启动开发服务器",
|
||||
"configureButton": "配置",
|
||||
"stopAndEditButton": "停止开发服务器并解决问题",
|
||||
"fixScript": "修复开发脚本"
|
||||
"fixScript": "修复开发脚本",
|
||||
"editDevScript": "编辑开发服务器脚本",
|
||||
"learnMore": "了解更多关于测试应用程序的信息"
|
||||
},
|
||||
"devScript": {
|
||||
"saveAndStart": "保存并启动",
|
||||
@@ -140,7 +143,16 @@
|
||||
"openInTab": "在新标签页中打开",
|
||||
"stopDevServer": "停止开发服务器",
|
||||
"clickToEdit": "点击编辑 URL",
|
||||
"resetUrl": "重置为检测到的 URL"
|
||||
"resetUrl": "重置为检测到的 URL",
|
||||
"clearUrlOverride": "清除 URL 覆盖",
|
||||
"desktopView": "桌面视图",
|
||||
"mobileView": "移动视图 (390x844)",
|
||||
"responsiveView": "响应式视图(可调整大小)",
|
||||
"startDevServer": "启动开发服务器"
|
||||
},
|
||||
"loading": {
|
||||
"startingServer": "正在启动开发服务器...",
|
||||
"waitingForServer": "正在等待服务器..."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
||||
@@ -98,13 +98,16 @@
|
||||
"noServer": {
|
||||
"title": "沒有執行中的開發伺服器",
|
||||
"startPrompt": "請啟動開發伺服器以查看預覽",
|
||||
"setupTitle": "必須設定開發伺服器腳本才能使用預覽功能",
|
||||
"setupPrompt": "要使用即時預覽與點擊編輯,請為此專案新增開發伺服器腳本。",
|
||||
"companionPrompt": "要使用點擊編輯功能,請將瀏覽器 Companion 加到您的專案。",
|
||||
"companionLink": "查看安裝指南",
|
||||
"startButton": "啟動開發伺服器",
|
||||
"configureButton": "設定",
|
||||
"stopAndEditButton": "停止開發伺服器並解決問題",
|
||||
"fixScript": "修復開發腳本"
|
||||
"fixScript": "修復開發腳本",
|
||||
"editDevScript": "編輯開發伺服器腳本",
|
||||
"learnMore": "了解更多關於測試應用程式的資訊"
|
||||
},
|
||||
"devScript": {
|
||||
"saveAndStart": "儲存並啟動",
|
||||
@@ -140,7 +143,16 @@
|
||||
"openInTab": "在新分頁開啟",
|
||||
"stopDevServer": "停止開發伺服器",
|
||||
"clickToEdit": "點擊以編輯 URL",
|
||||
"resetUrl": "重設為偵測到的 URL"
|
||||
"resetUrl": "重設為偵測到的 URL",
|
||||
"clearUrlOverride": "清除 URL 覆寫",
|
||||
"desktopView": "桌面檢視",
|
||||
"mobileView": "行動裝置檢視 (390x844)",
|
||||
"responsiveView": "響應式檢視(可調整大小)",
|
||||
"startDevServer": "啟動開發伺服器"
|
||||
},
|
||||
"loading": {
|
||||
"startingServer": "正在啟動開發伺服器...",
|
||||
"waitingForServer": "正在等待伺服器..."
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
|
||||
@@ -64,11 +64,11 @@ export type DraftWorkspaceData = { message: string, project_id: string | null, r
|
||||
|
||||
export type DraftWorkspaceRepo = { repo_id: string, target_branch: string, };
|
||||
|
||||
export type PreviewUrlOverrideData = { url: string, };
|
||||
export type PreviewSettingsData = { url: string, screen_size: string | null, responsive_width: number | null, responsive_height: number | null, };
|
||||
|
||||
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 type ScratchPayload = { "type": "DRAFT_TASK", "data": string } | { "type": "DRAFT_FOLLOW_UP", "data": DraftFollowUpData } | { "type": "DRAFT_WORKSPACE", "data": DraftWorkspaceData } | { "type": "PREVIEW_SETTINGS", "data": PreviewSettingsData };
|
||||
|
||||
export enum ScratchType { DRAFT_TASK = "DRAFT_TASK", DRAFT_FOLLOW_UP = "DRAFT_FOLLOW_UP", DRAFT_WORKSPACE = "DRAFT_WORKSPACE", PREVIEW_URL_OVERRIDE = "PREVIEW_URL_OVERRIDE" }
|
||||
export enum ScratchType { DRAFT_TASK = "DRAFT_TASK", DRAFT_FOLLOW_UP = "DRAFT_FOLLOW_UP", DRAFT_WORKSPACE = "DRAFT_WORKSPACE", PREVIEW_SETTINGS = "PREVIEW_SETTINGS" }
|
||||
|
||||
export type Scratch = { id: string, payload: ScratchPayload, created_at: string, updated_at: string, };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user