feat(raw_apps): default split view, blue preview tab, fix dnd ghosting

This commit is contained in:
Guilhem Lemouel
2026-05-25 10:50:46 +02:00
parent 6df6ea4dd9
commit 59ee03dbc1
2 changed files with 310 additions and 293 deletions
@@ -5,11 +5,23 @@
label: string
/** Optional lucide-svelte (or compatible) component rendered at 12px before the label. */
icon?: any
/** Optional class applied to the icon (e.g. `text-accent` to tint it). */
iconClass?: string
/** Optional class applied to the label text (e.g. `text-accent` to tint it). */
labelClass?: string
/** Defaults to true. Set false to hide the × close button. */
closable?: boolean
/** Pinned tabs are rendered outside the drag zone — 'left' or 'right' of the draggable group. */
pinned?: 'left' | 'right'
}
// Per-instance counter so every DraggableTabs gets its own dnd zone `type`.
// svelte-dnd-action treats zones sharing a `type` as one connected pool:
// two bars rendering the same tab ids (e.g. the mirrored single-view bars)
// would let a drag in one bar mark the matching item as a hidden shadow
// placeholder in the other and never clear it. Unique types keep them
// independent. We never drag tabs between two bars, so this loses nothing.
let dndZoneSeq = 0
</script>
<script lang="ts">
@@ -17,6 +29,7 @@
import { X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { createScrollArea, melt } from '@melt-ui/svelte'
import { untrack } from 'svelte'
interface Props {
tabs: TabItem[]
@@ -30,20 +43,32 @@
trailing?: import('svelte').Snippet
}
let {
tabs,
activeId,
onSelect,
onClose,
onReorder,
class: c = '',
trailing
}: Props = $props()
let { tabs, activeId, onSelect, onClose, onReorder, class: c = '', trailing }: Props = $props()
const pinnedLeft = $derived(tabs.filter((t) => t.pinned === 'left'))
const middle = $derived(tabs.filter((t) => !t.pinned))
const pinnedRight = $derived(tabs.filter((t) => t.pinned === 'right'))
// Unique dnd zone type for this instance (see note in the module block).
const dndType = `draggable-tabs-${dndZoneSeq++}`
// Local copy of the draggable items that the dnd zone owns and renders.
// We deliberately do NOT push `handleConsider` updates back to the parent:
// mid-drag, svelte-dnd-action injects a hidden "shadow placeholder" item
// into `e.detail.items`. If that reached the parent `tabs`, any sibling
// DraggableTabs bound to the same `tabs` (e.g. the mirrored single-view
// bars) would render the placeholder as a `visibility:hidden` ghost and
// keep it after the drag — the disappearing-tab bug. So consider only
// updates this local list (live feedback for the dragged bar); we commit
// to the parent on finalize, when the placeholder is already gone.
let dndMiddle = $state<TabItem[]>(untrack(() => middle))
let isDragging = false
$effect(() => {
const next = middle
// Re-sync from props except mid-drag, where the dnd zone owns the list.
if (!isDragging) dndMiddle = next
})
// Melt scroll-area: type='always' keeps the custom thumb's gutter present
// at all times, so the layout never shifts when content stops/starts
// overflowing. The native horizontal scrollbar is hidden by melt and our
@@ -52,15 +77,14 @@
elements: { root, viewport, content, scrollbarX, thumbX }
} = createScrollArea({ type: 'always', dir: 'ltr' })
function rebuild(middleNew: TabItem[]) {
onReorder?.([...pinnedLeft, ...middleNew, ...pinnedRight])
}
function handleConsider(e: CustomEvent<DndEvent<TabItem>>) {
rebuild(e.detail.items)
isDragging = true
dndMiddle = e.detail.items
}
function handleFinalize(e: CustomEvent<DndEvent<TabItem>>) {
rebuild(e.detail.items)
isDragging = false
dndMiddle = e.detail.items
onReorder?.([...pinnedLeft, ...e.detail.items, ...pinnedRight])
}
function tabClasses(isActive: boolean) {
@@ -107,15 +131,15 @@
role="tab"
aria-selected={isActive}
tabindex={isActive ? 0 : -1}
class={tabClasses(isActive)}
class={twMerge(tabClasses(isActive), tab.closable !== false && 'pr-1')}
onclick={() => onSelect(tab.id)}
onauxclick={(e) => handleAuxClick(e, tab)}
onkeydown={(e) => handleKeydown(e, tab)}
>
{#if Icon}
<Icon size={12} />
<Icon size={12} class={tab.iconClass} />
{/if}
<span class="truncate max-w-[180px]">{tab.label}</span>
<span class={twMerge('truncate max-w-[180px]', tab.labelClass)}>{tab.label}</span>
{#if tab.closable !== false}
<button
type="button"
@@ -132,9 +156,7 @@
</div>
{/snippet}
<div
class={twMerge('flex items-center bg-surface-secondary', c)}
>
<div class={twMerge('flex items-center bg-surface-secondary', c)}>
<div use:melt={$root} class="tabs-root flex-1 min-w-0 relative pt-1 pl-1 pb-1">
<div use:melt={$viewport} class="tabs-viewport w-full">
<!-- Inner flex wrapper — melt's content element is forced to
@@ -148,15 +170,15 @@
<div
class="flex items-center"
use:dndzone={{
items: middle,
items: dndMiddle,
flipDurationMs: 150,
type: 'draggable-tabs',
type: dndType,
dropTargetStyle: {}
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each middle as tab (tab.id)}
{#each dndMiddle as tab (tab.id)}
<div>
{@render tabButton(tab)}
</div>
@@ -29,10 +29,8 @@
import { createAppSelectedContext, type AppCodeSelectionElement } from '../copilot/chat/context'
import { rawAppLintStore } from './lintStore'
import { dbSchemas } from '$lib/stores'
import { MousePointerSquareDashed, RefreshCw, Columns2, ChevronDown } from 'lucide-svelte'
import DraggableTabs, {
type TabItem
} from '$lib/components/common/tabs/DraggableTabs.svelte'
import { MousePointerSquareDashed, RefreshCw, Columns2, ChevronDown, Eye } from 'lucide-svelte'
import DraggableTabs, { type TabItem } from '$lib/components/common/tabs/DraggableTabs.svelte'
import { runScriptAndPollResult } from '../jobs/utils'
import { RawAppHistoryManager } from './RawAppHistoryManager.svelte'
import { sendUserToast } from '$lib/utils'
@@ -212,12 +210,15 @@
const previewTab: TabItem = {
id: PREVIEW_TAB_ID,
label: 'Preview',
icon: Eye,
iconClass: 'text-accent',
labelClass: 'text-accent',
closable: false,
pinned: 'right'
}
let tabs: TabItem[] = $state([previewTab])
let activeTabId: string = $state(PREVIEW_TAB_ID)
let splitWithPreview: boolean = $state(false)
let splitWithPreview: boolean = $state(true)
const TABS_STORAGE_KEY = $derived(`raw-app-tabs:${$workspaceStore ?? ''}:${path ?? ''}`)
const activeTabKind = $derived<'file' | 'runnable' | 'preview'>(
activeTabId === PREVIEW_TAB_ID
@@ -304,6 +305,24 @@
return filePath.split('/').pop() || filePath
}
// Pick the file to open by default on a fresh load so the split view
// shows the editor + preview instead of a blank full-screen preview.
// Prefer the main `App.*` component, then the `index.*` entry, then any
// other source file, falling back to whatever exists.
function pickDefaultFile(f: Record<string, string> | undefined): string | undefined {
if (!f) return undefined
const keys = Object.keys(f).filter((p) => !p.endsWith('/'))
if (keys.length === 0) return undefined
const isSource = (p: string) =>
/\.(tsx|jsx|ts|js|svelte|vue)$/i.test(p) && !/package(-lock)?\.json$/i.test(p)
return (
keys.find((p) => /(^|\/)App\.(tsx|jsx|ts|js|svelte|vue)$/i.test(p)) ??
keys.find((p) => /(^|\/)index\.(tsx|jsx|ts|js)$/i.test(p)) ??
keys.find(isSource) ??
keys[0]
)
}
function activateTab(id: string, opts?: { force?: boolean }) {
const tab = tabs.find((t) => t.id === id)
if (!tab) return
@@ -429,12 +448,8 @@
let rawSidebarSize = $state(15)
let splitContainerWidth = $state(0)
const SIDEBAR_PX_MIN = 160
const sidebarMinPercent = $derived(
paneMinPercent(splitContainerWidth, SIDEBAR_PX_MIN)
)
const sidebarPanelSize = $derived(
Math.max(rawSidebarSize, sidebarMinPercent)
)
const sidebarMinPercent = $derived(paneMinPercent(splitContainerWidth, SIDEBAR_PX_MIN))
const sidebarPanelSize = $derived(Math.max(rawSidebarSize, sidebarMinPercent))
// Persisted across opens. Seeded with `defaultSidebarCollapsed` only when
// localStorage has no entry yet — callers (like the session preview pane)
@@ -1048,9 +1063,7 @@
// ≥1760px viewports, so this re-evaluates on resize via the listener below.
let editorFontSize = $state(12)
function recomputeEditorFontSize() {
const rootPx = parseFloat(
getComputedStyle(document.documentElement).fontSize
)
const rootPx = parseFloat(getComputedStyle(document.documentElement).fontSize)
// text-xs is 0.75rem
editorFontSize = rootPx * 0.75
}
@@ -1082,25 +1095,16 @@
// Push dark mode to both children. The UI Builder iframe and the
// preview iframe each listen for `setDarkMode` separately.
if (iframe && iframeLoaded) {
iframe.contentWindow?.postMessage(
{ type: 'setDarkMode', dark: darkMode },
'*'
)
iframe.contentWindow?.postMessage({ type: 'setDarkMode', dark: darkMode }, '*')
}
if (previewIframe && previewIframeLoaded) {
previewIframe.contentWindow?.postMessage(
{ type: 'setDarkMode', dark: darkMode },
'*'
)
previewIframe.contentWindow?.postMessage({ type: 'setDarkMode', dark: darkMode }, '*')
}
})
$effect(() => {
// Match VS Code's editor font size to Windmill's text-xs.
if (iframe && iframeLoaded) {
iframe.contentWindow?.postMessage(
{ type: 'setFontSize', px: editorFontSize },
'*'
)
iframe.contentWindow?.postMessage({ type: 'setFontSize', px: editorFontSize }, '*')
}
})
$effect(() => {
@@ -1189,6 +1193,14 @@
} catch (e) {
console.warn('Failed to hydrate raw-app tabs from localStorage', e)
} finally {
// Fresh app (no saved file tab — only Preview): open a default file
// and activate it. With `splitWithPreview` defaulting to true this
// renders the editor + preview side-by-side and boots the UI
// Builder iframe (which then builds the preview).
if (tabs.length === 1) {
const def = pickDefaultFile(files)
if (def) activateTab(ensureFileTab(def))
}
tabsHydrated = true
}
})
@@ -1375,55 +1387,55 @@
minSize={sidebarMinPercent}
class="h-full overflow-y-auto relative"
>
<RawAppSidebar
bind:files={
() => files,
(newFiles) => {
files = newFiles
setFilesInIframe(newFiles ?? {})
<RawAppSidebar
bind:files={
() => files,
(newFiles) => {
files = newFiles
setFilesInIframe(newFiles ?? {})
}
}
}
onSelectFile={handleSelectFile}
bind:selectedRunnable
bind:selectedDocument
dataTableRefs={dataTableRefsObjects}
onDataTableRefsChange={(newRefs) => {
data.tables = newRefs.map(formatDataTableRef)
}}
defaultDatatable={data.datatable}
defaultSchema={data.schema}
onDefaultChange={(datatable, schema) => {
data.datatable = datatable
data.schema = schema
// Also sync to aiChatManager
aiChatManager.datatableCreationPolicy = {
...aiChatManager.datatableCreationPolicy,
datatable,
schema
}
}}
{runnables}
{modules}
{historyManager}
historySelectedId={historyManager.selectedEntryId}
onHistorySelect={handleHistorySelect}
onHistorySelectCurrent={() => {
// Restore the temporary current state if it exists
const tempState = historyManager.getAndClearTemporaryState()
if (tempState) {
applyEntry(tempState)
}
// Clear selection to indicate we're at current state
historyManager.clearSelection()
}}
onManualSnapshot={() => {
historyManager.manualSnapshot(files ?? {}, runnables, summary, data, true)
}}
></RawAppSidebar>
</Pane>
{/if}
<Pane>
<!--
onSelectFile={handleSelectFile}
bind:selectedRunnable
bind:selectedDocument
dataTableRefs={dataTableRefsObjects}
onDataTableRefsChange={(newRefs) => {
data.tables = newRefs.map(formatDataTableRef)
}}
defaultDatatable={data.datatable}
defaultSchema={data.schema}
onDefaultChange={(datatable, schema) => {
data.datatable = datatable
data.schema = schema
// Also sync to aiChatManager
aiChatManager.datatableCreationPolicy = {
...aiChatManager.datatableCreationPolicy,
datatable,
schema
}
}}
{runnables}
{modules}
{historyManager}
historySelectedId={historyManager.selectedEntryId}
onHistorySelect={handleHistorySelect}
onHistorySelectCurrent={() => {
// Restore the temporary current state if it exists
const tempState = historyManager.getAndClearTemporaryState()
if (tempState) {
applyEntry(tempState)
}
// Clear selection to indicate we're at current state
historyManager.clearSelection()
}}
onManualSnapshot={() => {
historyManager.manualSnapshot(files ?? {}, runnables, summary, data, true)
}}
></RawAppSidebar>
</Pane>
{/if}
<Pane>
<!--
Per-pane tab bars (VS Code-style). Each pane carries its own
DraggableTabs instance so the splitter goes floor-to-ceiling
through tabs AND content in split mode. In single mode both
@@ -1431,214 +1443,197 @@
keeps every tab accessible, fixing the bug where activating
Preview previously hid every tab.
-->
<div
class="h-full w-full min-h-0 {splitWithPreview &&
activeTabKind !== 'preview'
? 'tabs-content-split'
: 'tabs-content-single'}"
>
<Splitpanes>
<Pane bind:size={paneALeftSize} minSize={0}>
<div class="flex flex-col h-full w-full min-h-0">
<DraggableTabs
tabs={leftPaneTabs}
activeId={activeTabId}
onSelect={(id) => activateTab(id)}
onClose={(id) => closeTab(id)}
onReorder={(next) => reorderTabs(next)}
>
{#snippet trailing()}
<div class="flex items-center gap-1 px-2">
<button
title={splitWithPreview
? 'Move preview back into a tab'
: 'Pin preview to the right'}
aria-label="Toggle split with preview"
aria-pressed={splitWithPreview}
class={splitWithPreview
? 'cursor-pointer bg-surface-accent-selected text-accent border border-border-selected w-7 h-7 rounded-md inline-flex items-center justify-center'
: 'cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary w-7 h-7 rounded-md inline-flex items-center justify-center'}
onclick={toggleSplit}
>
<Columns2 size={14} />
</button>
</div>
{/snippet}
</DraggableTabs>
<div class="flex-1 min-h-0 relative">
<div
class="absolute inset-0"
style="display: {showSource ? 'block' : 'none'}"
<div
class="h-full w-full min-h-0 {splitWithPreview && activeTabKind !== 'preview'
? 'tabs-content-split'
: 'tabs-content-single'}"
>
<Splitpanes>
<Pane bind:size={paneALeftSize} minSize={0}>
<div class="flex flex-col h-full w-full min-h-0">
<DraggableTabs
tabs={leftPaneTabs}
activeId={activeTabId}
onSelect={(id) => activateTab(id)}
onClose={(id) => closeTab(id)}
onReorder={(next) => reorderTabs(next)}
>
{#if iframeShouldMount}
<iframe
bind:this={iframe}
title="UI builder"
src="/ui_builder/index.html"
class="w-full h-full block"
></iframe>
{/if}
</div>
<div
class="absolute inset-0"
style="display: {showRunnable ? 'block' : 'none'}"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex h-full w-full">
{#if selectedRunnable !== undefined}
<RawAppInlineScriptsPanel
appPath={path}
{selectedRunnable}
bind:runnables
onSelectionChange={(selection) => {
if (selection === null) {
codeSelection = undefined
} else if (selectedRunnable) {
codeSelection = {
type: 'app_code_selection',
source: selectedRunnable,
sourceType: 'backend',
title: `${selectedRunnable}:L${selection.startLine}-L${selection.endLine}`,
content: selection.content,
startLine: selection.startLine,
endLine: selection.endLine,
startColumn: selection.startColumn,
endColumn: selection.endColumn
}
}
}}
/>
{#snippet trailing()}
<div class="flex items-center gap-1 px-2">
<button
title={splitWithPreview
? 'Move preview back into a tab'
: 'Pin preview to the right'}
aria-label="Toggle split with preview"
aria-pressed={splitWithPreview}
class={splitWithPreview
? 'cursor-pointer bg-surface-accent-selected text-accent border border-border-selected w-7 h-7 rounded-md inline-flex items-center justify-center'
: 'cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary w-7 h-7 rounded-md inline-flex items-center justify-center'}
onclick={toggleSplit}
>
<Columns2 size={14} />
</button>
</div>
{/snippet}
</DraggableTabs>
<div class="flex-1 min-h-0 relative">
<div class="absolute inset-0" style="display: {showSource ? 'block' : 'none'}">
{#if iframeShouldMount}
<iframe
bind:this={iframe}
title="UI builder"
src="/ui_builder/index.html"
class="w-full h-full block"
></iframe>
{/if}
</div>
<div class="absolute inset-0" style="display: {showRunnable ? 'block' : 'none'}">
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex h-full w-full">
{#if selectedRunnable !== undefined}
<RawAppInlineScriptsPanel
appPath={path}
{selectedRunnable}
bind:runnables
onSelectionChange={(selection) => {
if (selection === null) {
codeSelection = undefined
} else if (selectedRunnable) {
codeSelection = {
type: 'app_code_selection',
source: selectedRunnable,
sourceType: 'backend',
title: `${selectedRunnable}:L${selection.startLine}-L${selection.endLine}`,
content: selection.content,
startLine: selection.startLine,
endLine: selection.endLine,
startColumn: selection.startColumn,
endColumn: selection.endColumn
}
}
}}
/>
{/if}
</div>
</div>
</div>
</div>
</div>
</Pane>
<Pane bind:size={paneBRightSize} minSize={0}>
<div class="flex flex-col h-full w-full min-h-0 relative">
<DraggableTabs
tabs={rightPaneTabs}
activeId={rightPaneActiveId}
onSelect={(id) => activateTab(id)}
onClose={(id) => closeTab(id)}
onReorder={(next) => reorderTabs(next)}
>
{#snippet trailing()}
<div class="flex items-center gap-1 px-2">
<button
class="cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary px-2 h-7 rounded-md text-xs"
title="Switch bundler"
onclick={() => {
const next =
bundlerType === 'esbuild' ? 'rolldown' : 'esbuild'
bundlerType = next
iframe?.contentWindow?.postMessage(
{ type: 'setBundlerType', bundlerType: next },
'*'
)
}}>{bundlerType}</button
>
<button
title={inspectorEnabled
? 'Click to disable element inspector'
: 'Click to enable element inspector'}
class={inspectorEnabled
? 'cursor-pointer bg-surface-accent-selected text-accent border border-border-selected w-7 h-7 rounded-md inline-flex items-center justify-center'
: 'cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary w-7 h-7 rounded-md inline-flex items-center justify-center'}
aria-label="Toggle element inspector"
onclick={() => {
inspectorEnabled = !inspectorEnabled
previewIframe?.contentWindow?.postMessage(
{
type: inspectorEnabled
? 'inspectorEnable'
: 'inspectorDisable'
},
'*'
)
}}
>
<MousePointerSquareDashed size={14} />
</button>
<button
class="cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary w-7 h-7 rounded-md inline-flex items-center justify-center"
title="Replay the last build into the preview"
aria-label="Rebuild"
onclick={() => {
if (lastBuild) {
</Pane>
<Pane bind:size={paneBRightSize} minSize={0}>
<div class="flex flex-col h-full w-full min-h-0 relative">
<DraggableTabs
tabs={rightPaneTabs}
activeId={rightPaneActiveId}
onSelect={(id) => activateTab(id)}
onClose={(id) => closeTab(id)}
onReorder={(next) => reorderTabs(next)}
>
{#snippet trailing()}
<div class="flex items-center gap-1 px-2">
<button
class="cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary px-2 h-7 rounded-md text-xs"
title="Switch bundler"
onclick={() => {
const next = bundlerType === 'esbuild' ? 'rolldown' : 'esbuild'
bundlerType = next
iframe?.contentWindow?.postMessage(
{ type: 'setBundlerType', bundlerType: next },
'*'
)
}}>{bundlerType}</button
>
<button
title={inspectorEnabled
? 'Click to disable element inspector'
: 'Click to enable element inspector'}
class={inspectorEnabled
? 'cursor-pointer bg-surface-accent-selected text-accent border border-border-selected w-7 h-7 rounded-md inline-flex items-center justify-center'
: 'cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary w-7 h-7 rounded-md inline-flex items-center justify-center'}
aria-label="Toggle element inspector"
onclick={() => {
inspectorEnabled = !inspectorEnabled
previewIframe?.contentWindow?.postMessage(
{
type: 'preview',
css: lastBuild.css,
js: lastBuild.js
type: inspectorEnabled ? 'inspectorEnable' : 'inspectorDisable'
},
'*'
)
}
}}
>
<RefreshCw size={14} />
</button>
<button
title={splitWithPreview
? 'Move preview back into a tab'
: 'Pin preview to the right'}
aria-label="Toggle split with preview"
aria-pressed={splitWithPreview}
class={splitWithPreview
? 'cursor-pointer bg-surface-accent-selected text-accent border border-border-selected w-7 h-7 rounded-md inline-flex items-center justify-center'
: 'cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary w-7 h-7 rounded-md inline-flex items-center justify-center'}
onclick={toggleSplit}
>
<Columns2 size={14} />
</button>
</div>
{/snippet}
</DraggableTabs>
<iframe
bind:this={previewIframe}
title="App preview"
src="/ui_builder/app-preview.html"
class="w-full flex-1 block"
></iframe>
{#if logs}
<div
class="absolute right-0 bottom-0 z-20 max-w-[500px] w-full flex flex-col text-xs p-1 border border-border-light rounded-tl-md bg-surface text-primary {logsCollapsed
? 'h-6'
: 'max-h-60 h-full'}"
>
<button
class="cursor-pointer flex items-center gap-2 w-full text-xs font-normal text-secondary -mt-0.5 px-2 text-left"
onclick={() => (logsCollapsed = !logsCollapsed)}
>
Logs
<ChevronDown
size={12}
class="transition duration-200"
style="transform: {logsCollapsed
? 'rotate(180deg)'
: 'rotate(0deg)'}"
/>
<span class="text-secondary"
>({logs.split('\n').length})</span
>
</button>
}}
>
<MousePointerSquareDashed size={14} />
</button>
<button
class="cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary w-7 h-7 rounded-md inline-flex items-center justify-center"
title="Replay the last build into the preview"
aria-label="Rebuild"
onclick={() => {
if (lastBuild) {
previewIframe?.contentWindow?.postMessage(
{
type: 'preview',
css: lastBuild.css,
js: lastBuild.js
},
'*'
)
}
}}
>
<RefreshCw size={14} />
</button>
<button
title={splitWithPreview
? 'Move preview back into a tab'
: 'Pin preview to the right'}
aria-label="Toggle split with preview"
aria-pressed={splitWithPreview}
class={splitWithPreview
? 'cursor-pointer bg-surface-accent-selected text-accent border border-border-selected w-7 h-7 rounded-md inline-flex items-center justify-center'
: 'cursor-pointer bg-surface hover:bg-surface-hover border border-border-light text-primary w-7 h-7 rounded-md inline-flex items-center justify-center'}
onclick={toggleSplit}
>
<Columns2 size={14} />
</button>
</div>
{/snippet}
</DraggableTabs>
<iframe
bind:this={previewIframe}
title="App preview"
src="/ui_builder/app-preview.html"
class="w-full flex-1 block"
></iframe>
{#if logs}
<div
bind:this={logsDiv}
class="logs-scroll grow w-full overflow-auto"
class="absolute right-0 bottom-0 z-20 max-w-[500px] w-full flex flex-col text-xs p-1 border border-border-light rounded-tl-md bg-surface text-primary {logsCollapsed
? 'h-6'
: 'max-h-60 h-full'}"
>
{#if !logsCollapsed}
<pre>{logs}</pre>
{/if}
<button
class="cursor-pointer flex items-center gap-2 w-full text-xs font-normal text-secondary -mt-0.5 px-2 text-left"
onclick={() => (logsCollapsed = !logsCollapsed)}
>
Logs
<ChevronDown
size={12}
class="transition duration-200"
style="transform: {logsCollapsed ? 'rotate(180deg)' : 'rotate(0deg)'}"
/>
<span class="text-secondary">({logs.split('\n').length})</span>
</button>
<div bind:this={logsDiv} class="logs-scroll grow w-full overflow-auto">
{#if !logsCollapsed}
<pre>{logs}</pre>
{/if}
</div>
</div>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
</div>
</Pane>
</Splitpanes>
{/if}
</div>
</Pane>
</Splitpanes>
</div>
</Pane>
</Splitpanes>
</div>
</div>