feat(raw_apps): tab-based editor surface with split-with-preview (#9273)

* feat(raw_apps): custom tab system for source / runnable / preview

Replaces the fixed split-pane layout with a tab bar inside the editor
area. Each frontend file is a tab, each selected runnable is a tab,
and the Preview is pinned to the right (non-closable). Tabs are an
alternative discoverability surface to the sidebar — both stay
functional, but tabs make navigation viable on small screens with
the sidebar collapsed.

A "Split with Preview" toggle in the tab bar's trailing slot pairs
the active tab with the preview side-by-side for wide-screen
multitasking. The toggle hides when Preview is already the active
tab.

The UI Builder, runnable editor, and preview iframe all stay mounted
across tab switches (toggled via `display`) — no bundler restarts, no
preview state loss, no editor remounts.

- New common/tabs/DraggableTabs.svelte: reusable tab strip with
  drag-reorder (@windmill-labs/svelte-dnd-action), pinned-left/right
  slots excluded from the drag zone, hover-revealed X close, middle-
  click close, keyboard navigation (arrows / Enter / Backspace),
  and a `trailing` snippet for inline toolbar add-ons.
- raw_apps/RawAppEditor.svelte:
  - Tab state (`tabs`, `activeTabId`, `splitWithPreview`) lives in
    Windmill. Persisted in localStorage keyed by workspace + app path.
  - Sidebar file clicks (`handleSelectFile`) and runnable selection
    (`selectedRunnable` via `bind:`) are mirrored into tabs via an
    effect — the sidebar interaction is otherwise untouched.
  - Listener augmented: `setActiveDocument` backfills tabs for files
    VS Code opens by itself; `setFiles` / `runnables` updates drop
    stale tabs.
  - Bundler / inspector / rebuild toolbar moves into the tab bar's
    trailing slot — always visible regardless of active tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(raw_apps): modern tab styling + resizable split-with-preview

Two polish passes on the new tab system:

DraggableTabs styling:
- Remove the bottom border on the tab strip + the accent-coloured
  border-b-2 on the active tab. The active tab now shares the
  surface background with the content area below it, so the
  boundary visually "disappears" — modern IDE-style tabs.
- Inactive tabs sit on the darker surface-secondary tab strip and
  get a subtle right separator so they don't blur into each other.

Split-with-Preview is now a real resizable Splitpanes:
- The content area is rendered as a Splitpanes (always), with the
  source/runnable slot on the left and the preview iframe on the
  right. The user can drag the divider to adjust the ratio when
  the "Split with Preview" toggle is on.
- Iframes never remount across single↔split toggles — pane sizes
  are driven reactively from (activeTabKind, splitWithPreview),
  not by adding/removing the Splitpanes itself.
- The user's preferred split ratio is remembered while they're
  dragging and reapplied next time split is enabled.
- The inner splitter is CSS-hidden in single mode so the toggle
  button stays the single canonical way to flip layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): split mode moves preview tab into the right pane

Cleaner mental model for split-with-preview. Instead of "split the
active tab + always keep the Preview tab around", the Split toggle
now physically moves the Preview tab out of the bar and into a
permanent right pane. When the user toggles split off, the Preview
tab reappears in the bar like any other tab.

- New `displayedTabs` derived: filters out the Preview tab when
  splitWithPreview is on, so the user sees only file/runnable tabs
  in the bar and a dedicated preview pane on the right.
- `toggleSplit` redirects the active tab to the most recent
  file/runnable when the user toggles split on with Preview active,
  so they don't end up staring at an empty left pane.
- Split toggle is now always visible — the user can flip both ways.
  The button label flips between "Pin preview to the right" and
  "Move preview back into a tab" to reflect what's about to happen.
- reorderTabs preserves the Preview tab in the underlying `tabs`
  array even though it's filtered out of the drag set in split mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(raw_apps): VS Code-style "Preview" header on the right pane

In split mode, the right pane now shows a small "Preview" tab-styled
header anchored at its top-left — making the layout read like a real
VS Code editor split, where each group has its own tab bar.

- Header appears only when `splitWithPreview && activeTabKind !== 'preview'`
  (i.e. when the right pane is meaningfully separate from the left's
  content). In single mode with preview active, the right pane is the
  only thing visible and the main tab bar already labels it.
- The header uses the same styling as an active tab: `bg-surface`
  on a `bg-surface-secondary` strip, h-8, text-xs, no border.
- An X button next to the label toggles split off — equivalent to
  closing the editor in VS Code's split view (preview goes back to
  living as a tab in the main bar).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): VS Code-style symmetric tab bars per pane

Restructure the editor area so each pane is a self-contained "editor
group" with its own tab bar at the top. The Splitpanes is now the
topmost element — the divider runs floor-to-ceiling, splitting both
the tab bars and the content.

Layout (left pane = source / runnable, right pane = preview):
- Left pane top: DraggableTabs (file/runnable tabs, Preview tab when
  split is off) + Split-toggle in the trailing slot.
- Right pane top: a custom preview header — "Preview" label styled
  like an active tab on the left + the preview-affecting toolbar
  (bundler, inspector, rebuild) on the right.
- Each pane independently sized via Splitpanes; iframes + the
  runnable panel stay mounted and toggled via `display` so state
  survives every transition.

Trade-off: in single-mode with Preview active (paneA=0), the left
tab bar is hidden along with the left pane. To switch back to a
file tab the user uses the sidebar — which is exactly the
discoverability surface tabs were meant to complement, not replace.

Button placement by semantic ownership:
- Layout control (Split toggle) — left side, with the editor.
- Preview-affecting controls (bundler, inspector, rebuild) — right
  side, with the preview. No close-X on the right; the Split toggle
  on the left is the canonical way to flip layouts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(raw_apps): keep tab bar visible when Preview is active in single mode

The "VS Code-style" restructure put the tab bar inside the left
Pane. When activeTabKind became 'preview' in single mode, the left
pane collapsed to width 0 and the entire tab bar disappeared with
it — leaving the user with no way to switch back to a file tab
except via the sidebar.

Move the main tab bar back above the inner Splitpanes (full width,
always visible). The preview pseudo-header stays inside the right
pane, carrying the bundler / inspector / rebuild toolbar. The
splitter only goes through the content area below the tab bar,
which is acceptable given how much friction the disappearing-tabs
edge case caused.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): per-pane tab bars with mirrored single-mode lists

Replace the single tab bar above the inner Splitpanes with one
DraggableTabs per pane. Splitter now goes floor-to-ceiling through
tabs AND content in split mode.

In single mode both bars mirror the full tab list, so the visible
pane always carries every tab — fixes the bug where activating
Preview hid the tab strip. Clicking Preview while in split mode is
a no-op (Preview is permanently visible in the right pane).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(raw_apps): polish tab strip and sync editor font to text-xs

* feat(raw_apps): move logs overlay onto the preview pane

* refactor(splitpanes): extract pixel-aware minSize helper

* fix(raw_apps): tab hydration loads correct file; closeTab in split mode

* fix(raw_apps): lazy-mount UI Builder iframe + add dev:ui-builder script

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

* fix(raw_apps): remove 1px splitter sliver beside preview in single view

* fix(raw_apps): tab scrollbar on hover, fix thumb height + resize staleness

* refactor(raw_apps): don't persist tab/split layout in localStorage

* refactor(raw_apps): derive pane sizes + binding setter instead of effects

* style(raw_apps): trim verbose comments

* feat(raw_apps): accept appendLogs delta from the UI Builder iframe

* fix(raw_apps): exit inspect mode on Escape

* fix(raw_apps): Escape clears lingering inspector selection after pick

* style(raw_apps): accent-selected styling for active tab, bg-surface strip

* fix(raw_apps): address PR review nits (drop debug log, timer/reorder/pane-setter, dev script restore)

* fix(raw_apps): clear inspector overlay on the preview iframe, not the source

* style(raw_apps): neutral tab look (surface-tertiary/text-emphasis selected, text-hint idle)

* chore(raw_apps): bump bundled ui_builder to 61b6fdd

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-06-08 11:35:45 +02:00
committed by tristantr
co-authored by Claude Opus 4.7
parent 297dd722c9
commit ad4368206e
6 changed files with 966 additions and 137 deletions
+1
View File
@@ -3,6 +3,7 @@
"version": "1.708.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
"build": "vite build",
"build:utils": "vite build --config sharedUtils/vite.sharedUtils.config.js",
"preview": "vite preview",
+2 -2
View File
@@ -1,5 +1,5 @@
{
"baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev",
"version": "6715153",
"sha256": "1485930ea5f5309e4bdc09a55aae72eae8230eb74f0928715a0e6fe610703d9b"
"version": "61b6fdd",
"sha256": "d7c316b4429442eed9462756db0fdf13128849b5b9adf4a7b7acc7a26b1a7280"
}
@@ -1,5 +1,6 @@
<script lang="ts">
import { buildWsUrl } from '$lib/wsUrl'
import { paneMinPercent } from '$lib/utils/splitpaneSizing'
import { processSecretArgs } from './secretArgUtils'
import type { Schema, SupportedLanguage } from '$lib/common'
import {
@@ -1351,7 +1352,7 @@
let splitContainerWidth = $state(0)
const TEST_PANE_MIN_PX = 400
const testPaneMinPercent = $derived(
splitContainerWidth > 0 ? Math.min(80, (TEST_PANE_MIN_PX / splitContainerWidth) * 100) : 0
paneMinPercent(splitContainerWidth, TEST_PANE_MIN_PX)
)
// Raw user-controlled test size (what the splitter wrote, or what the
@@ -0,0 +1,251 @@
<script lang="ts" module>
export type TabItem = {
/** Stable identifier; used as the `[key]` for dnd and the activeId equality check. */
id: string
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 dnd zone `type` so sibling bars (mirrored single-view) don't
// share svelte-dnd-action's item pool — otherwise a drag in one ghosts the
// matching tab in the other.
let dndZoneSeq = 0
</script>
<script lang="ts">
import { dndzone, type DndEvent } from '@windmill-labs/svelte-dnd-action'
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[]
activeId: string
onSelect: (id: string) => void
onClose?: (id: string) => void
onReorder?: (newOrder: TabItem[]) => void
/** Extra classes for the outer tab strip. */
class?: string
/** Render after the right-pinned tabs (e.g. a "Split with Preview" toggle). */
trailing?: import('svelte').Snippet
}
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 list the dnd zone owns. `consider` updates only this (mid-drag it
// holds svelte-dnd-action's shadow placeholder); we commit to the parent on
// `finalize` so the placeholder never leaks into a sibling bar.
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
})
// `type: 'hover'` shows the custom bar only while hovering/scrolling the strip.
const {
elements: { root, viewport, content, scrollbarX, thumbX }
} = createScrollArea({ type: 'hover', hideDelay: 600, dir: 'ltr' })
// melt only re-measures the thumb when its *content* resizes, not the
// viewport — so a pane resize leaves the thumb stale. Detect width changes
// via `bind:clientWidth` and nudge melt by perturbing the 0×0 sentinel's box.
let viewportWidth = $state(0)
let resizeSentinel: HTMLSpanElement | undefined = $state(undefined)
$effect(() => {
void viewportWidth
const el = untrack(() => resizeSentinel)
if (!el) return
el.style.width = '1px'
const raf = requestAnimationFrame(() => {
el.style.width = '0px'
})
return () => cancelAnimationFrame(raf)
})
function handleConsider(e: CustomEvent<DndEvent<TabItem>>) {
isDragging = true
dndMiddle = e.detail.items
}
function handleFinalize(e: CustomEvent<DndEvent<TabItem>>) {
isDragging = false
dndMiddle = e.detail.items
onReorder?.([...pinnedLeft, ...e.detail.items, ...pinnedRight])
}
function tabClasses(isActive: boolean) {
return twMerge(
'group inline-flex items-center gap-1.5 px-2.5 h-7 text-xs rounded-md select-none cursor-pointer whitespace-nowrap transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-border-selected focus-visible:ring-inset',
isActive
? 'bg-surface-tertiary text-emphasis'
: 'bg-transparent text-hint hover:text-secondary'
)
}
function handleKeydown(e: KeyboardEvent, tab: TabItem) {
if (e.key === 'Delete' || e.key === 'Backspace') {
if (tab.closable !== false) {
e.preventDefault()
onClose?.(tab.id)
}
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
const idx = tabs.findIndex((t) => t.id === tab.id)
const next = e.key === 'ArrowLeft' ? idx - 1 : idx + 1
if (next >= 0 && next < tabs.length) {
onSelect(tabs[next].id)
e.preventDefault()
}
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect(tab.id)
}
}
function handleAuxClick(e: MouseEvent, tab: TabItem) {
if (e.button === 1 && tab.closable !== false) {
e.preventDefault()
onClose?.(tab.id)
}
}
</script>
{#snippet tabButton(tab: TabItem)}
{@const isActive = tab.id === activeId}
{@const Icon = tab.icon}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="tab"
aria-selected={isActive}
tabindex={isActive ? 0 : -1}
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} class={tab.iconClass} />
{/if}
<span class={twMerge('truncate max-w-[180px]', tab.labelClass)}>{tab.label}</span>
{#if tab.closable !== false}
<button
type="button"
class="opacity-0 group-hover:opacity-100 focus:opacity-100 rounded hover:bg-surface-hover w-4 h-4 inline-flex items-center justify-center"
aria-label={`Close ${tab.label}`}
onclick={(e) => {
e.stopPropagation()
onClose?.(tab.id)
}}
>
<X size={10} />
</button>
{/if}
</div>
{/snippet}
<div class={twMerge('flex items-center bg-surface', c)}>
<div use:melt={$root} class="tabs-root flex-1 min-w-0 relative pt-1 pl-1 pb-1">
<div use:melt={$viewport} bind:clientWidth={viewportWidth} class="tabs-viewport w-full">
<!-- Inner flex wrapper — melt's content element is forced to
`display: table` which would stack the tabs vertically. -->
<div use:melt={$content}>
<div class="flex items-center" role="tablist">
{#each pinnedLeft as tab (tab.id)}
{@render tabButton(tab)}
{/each}
<div
class="flex items-center"
use:dndzone={{
items: dndMiddle,
flipDurationMs: 150,
type: dndType,
dropTargetStyle: {}
}}
onconsider={handleConsider}
onfinalize={handleFinalize}
>
{#each dndMiddle as tab (tab.id)}
<div>
{@render tabButton(tab)}
</div>
{/each}
</div>
{#each pinnedRight as tab (tab.id)}
{@render tabButton(tab)}
{/each}
<!-- resize nudge for melt (see the $effect above) -->
<span
bind:this={resizeSentinel}
aria-hidden="true"
style="display:inline-block;width:0;height:0"
></span>
</div>
</div>
</div>
<div use:melt={$scrollbarX} class="tabs-scrollbar">
<div use:melt={$thumbX} class="tabs-thumb"></div>
</div>
</div>
{#if trailing}
<div class="ml-1 pr-1 flex items-center shrink-0">
{@render trailing()}
</div>
{/if}
</div>
<style>
.tabs-viewport {
height: 100%;
}
/* Custom 4px bar, absolutely positioned so toggling it never shifts layout. */
:global([data-melt-scroll-area-scrollbar].tabs-scrollbar) {
height: 4px;
background: transparent;
touch-action: none;
user-select: none;
transition: opacity 0.15s;
}
/* Hide the whole bar when melt marks it hidden (key off the scrollbar, not
the thumb — the thumb's data-state doesn't track overflow). */
:global([data-melt-scroll-area-scrollbar].tabs-scrollbar[data-state='hidden']) {
opacity: 0;
pointer-events: none;
}
:global([data-melt-scroll-area-thumb].tabs-thumb) {
/* melt leaves the thumb-height var empty for a horizontal bar, collapsing
the inline height to 0 — supply it so the thumb fills the 4px track. */
--melt-scroll-area-thumb-height: 100%;
height: 100%;
width: var(--melt-scroll-area-thumb-width);
background: rgb(var(--color-text-hint) / 0.35);
border-radius: 2px;
position: relative;
transition: background-color 0.15s;
}
:global([data-melt-scroll-area-thumb].tabs-thumb:hover) {
background: rgb(var(--color-text-secondary) / 0.6);
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import { Pane, Splitpanes } from 'svelte-splitpanes'
import { paneMinPercent } from '$lib/utils/splitpaneSizing'
import RawAppInlineScriptsPanel from './RawAppInlineScriptsPanel.svelte'
import type { JobById } from '../apps/types'
import RawAppEditorHeader from './RawAppEditorHeader.svelte'
@@ -28,6 +29,8 @@
import { createAppSelectedContext, type AppCodeSelectionElement } from '../copilot/chat/context'
import { rawAppLintStore } from './lintStore'
import { dbSchemas } from '$lib/stores'
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'
@@ -182,9 +185,227 @@
historyManager.manualSnapshot(files ?? {}, runnables, summary, data)
let iframe: HTMLIFrameElement | undefined = $state(undefined)
let previewIframe: HTMLIFrameElement | undefined = $state(undefined)
let previewIframeLoaded = $state(false)
let lastBuild: { css: string; js: string } | undefined = undefined
let inspectorEnabled = $state(false)
let bundlerType: 'esbuild' | 'rolldown' = $state('esbuild')
// Build/bundler logs forwarded from the UI Builder iframe. We render
// them as an overlay inside the preview pane (right side) so they're
// visually tied to the build output, not to the source editor.
let logs = $state('')
let logsCollapsed = $state(false)
let logsDiv: HTMLDivElement | undefined = $state(undefined)
$effect(() => {
if (logsDiv && logs && !logsCollapsed) {
const t = setTimeout(() => logsDiv?.scrollTo(0, logsDiv.scrollHeight), 50)
return () => clearTimeout(t)
}
})
// Tab system — the source side of the editor area. The sidebar stays the
// primary navigation; tabs are a secondary surface that's useful when the
// sidebar is collapsed and on narrow viewports.
const PREVIEW_TAB_ID = 'preview'
const FILE_PREFIX = 'file:'
const RUNNABLE_PREFIX = 'runnable:'
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(true)
const activeTabKind = $derived<'file' | 'runnable' | 'preview'>(
activeTabId === PREVIEW_TAB_ID
? 'preview'
: activeTabId.startsWith(FILE_PREFIX)
? 'file'
: 'runnable'
)
// Single mode: both bars mirror the full list (the visible pane carries
// every tab). Split mode: left = files/runnables, right = Preview only.
const leftPaneTabs = $derived<TabItem[]>(
splitWithPreview ? tabs.filter((t) => t.id !== PREVIEW_TAB_ID) : tabs
)
const rightPaneTabs = $derived<TabItem[]>(
splitWithPreview ? tabs.filter((t) => t.id === PREVIEW_TAB_ID) : tabs
)
// In split mode the right bar always highlights Preview, regardless of the
// left pane's active file/runnable.
const rightPaneActiveId = $derived(splitWithPreview ? PREVIEW_TAB_ID : activeTabId)
const showSource = $derived(activeTabKind === 'file')
const showRunnable = $derived(activeTabKind === 'runnable')
// Mount the UI Builder iframe the first time a file is shown (paneA has
// width then; mounting it at 0-width breaks the VS Code workbench), and
// keep it mounted so tab switches don't reload it.
let iframeShouldMount = $state(false)
$effect(() => {
if (showSource) iframeShouldMount = true
})
// Inner pane sizes are a pure function of mode + active tab → derived.
// `paneARatio` is the user's last manual split drag (set by rememberPaneDrag).
let paneARatio = $state(50)
const paneALeftSize = $derived(
splitWithPreview && activeTabKind !== 'preview'
? paneARatio
: activeTabKind === 'preview'
? 0
: 100
)
const paneBRightSize = $derived(100 - paneALeftSize)
// Keep a manual drag only when it's a genuine in-between split (0/100 are
// the programmatic collapsed/full states, not user intent).
function rememberPaneDrag(v: number) {
if (splitWithPreview && activeTabKind !== 'preview' && v > 0 && v < 100) {
paneARatio = v
}
}
function fileTabId(filePath: string) {
return FILE_PREFIX + filePath
}
function runnableTabId(key: string) {
return RUNNABLE_PREFIX + key
}
function fileBaseName(filePath: string) {
return filePath.split('/').pop() || filePath
}
// Default file to open on load: prefer App.*, then index.*, then any source file.
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
// In split mode Preview is always shown on the right, so clicking it is a
// no-op (would collapse the left pane). `force` lets closeTab fall back to it.
if (!opts?.force && splitWithPreview && id === PREVIEW_TAB_ID) return
activeTabId = id
if (tab.id === PREVIEW_TAB_ID) {
selectedRunnable = undefined
} else if (tab.id.startsWith(FILE_PREFIX)) {
const filePath = tab.id.slice(FILE_PREFIX.length)
selectedRunnable = undefined
// `populateFiles` reads this on iframe load, so set it even if the
// iframe isn't ready yet (the postMessage below is then skipped).
selectedDocument = filePath
if (iframeLoaded) {
iframe?.contentWindow?.postMessage({ type: 'selectFile', path: filePath }, '*')
}
} else if (tab.id.startsWith(RUNNABLE_PREFIX)) {
const key = tab.id.slice(RUNNABLE_PREFIX.length)
if (selectedRunnable !== key) selectedRunnable = key
}
}
function ensureFileTab(filePath: string): string {
const id = fileTabId(filePath)
if (!tabs.some((t) => t.id === id)) {
// Insert before the pinned preview tab.
const insertAt = tabs.findIndex((t) => t.pinned === 'right')
const newTab: TabItem = {
id,
label: fileBaseName(filePath),
closable: true
}
tabs = [
...tabs.slice(0, insertAt === -1 ? tabs.length : insertAt),
newTab,
...tabs.slice(insertAt === -1 ? tabs.length : insertAt)
]
}
return id
}
function ensureRunnableTab(key: string): string {
const id = runnableTabId(key)
if (!tabs.some((t) => t.id === id)) {
const insertAt = tabs.findIndex((t) => t.pinned === 'right')
const newTab: TabItem = {
id,
label: key,
closable: true
}
tabs = [
...tabs.slice(0, insertAt === -1 ? tabs.length : insertAt),
newTab,
...tabs.slice(insertAt === -1 ? tabs.length : insertAt)
]
}
return id
}
function closeTab(id: string) {
const idx = tabs.findIndex((t) => t.id === id)
const tab = tabs[idx]
if (!tab || tab.closable === false) return
const wasActive = activeTabId === id
// Clear selection before removal so the cleanup $effect doesn't recreate it.
if (wasActive && tab.id.startsWith(RUNNABLE_PREFIX)) {
selectedRunnable = undefined
}
tabs = tabs.filter((t) => t.id !== id)
if (wasActive) {
// Fall back to the previous tab (force, in case it's Preview in split).
const fallback = tabs[Math.max(0, idx - 1)] ?? previewTab
activateTab(fallback.id, { force: true })
}
}
function reorderTabs(next: TabItem[]) {
// Rebuild from the reordered `next` plus any tabs that bar didn't show
// (e.g. files when the Preview-only right bar fires in split mode), with
// Preview kept pinned at the end — so a reorder can never drop a tab.
const seen = new Set(next.map((t) => t.id))
const rest = tabs.filter((t) => !seen.has(t.id))
tabs = [...next, ...rest].filter((t) => t.id !== PREVIEW_TAB_ID).concat(previewTab)
}
function toggleSplit() {
if (splitWithPreview) {
splitWithPreview = false
return
}
// single → split: if Preview is active, move focus to the last
// file/runnable tab (it's leaving the left bar).
if (activeTabId === PREVIEW_TAB_ID) {
const lastUserTab = tabs
.slice()
.reverse()
.find((t) => t.id !== PREVIEW_TAB_ID)
if (lastUserTab) activeTabId = lastUserTab.id
}
splitWithPreview = true
}
let yamlEditorDrawer: Drawer | undefined = $state(undefined)
let sidebarPanelSize = $state(15)
// Sidebar: honor the user's `%` but never shrink below SIDEBAR_PX_MIN.
// (svelte-splitpanes' minSize only blocks drag, so we clamp ourselves.)
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))
// Persisted across opens. Seeded with `defaultSidebarCollapsed` only when
// localStorage has no entry yet — callers (like the session preview pane)
@@ -267,12 +488,10 @@
}
let iframeLoaded = $state(false) // @hmr:keep
// Suppresses iframe-sourced events for a short window after we re-push files,
// to keep the iframe's boot-time messages from clobbering the user's state.
// suppressIframeSetFiles is held across an entire iframe reload (e.g. theme switch);
// the timer is reset on every reload so rapid toggles don't clear it prematurely.
// Briefly drops the `setActiveDocument` echo VS Code fires while we're
// pushing the initial file set — the iframe auto-opens a default editor
// during boot which we don't want to treat as a user-driven activation.
let suppressSetActiveDocument = false
let suppressIframeSetFiles = false
let suppressTimer: ReturnType<typeof setTimeout> | undefined
let sharedUiFiles: Record<string, string> = $state({})
@@ -313,7 +532,6 @@
if (suppressTimer !== undefined) clearTimeout(suppressTimer)
suppressTimer = setTimeout(() => {
suppressSetActiveDocument = false
suppressIframeSetFiles = false
suppressTimer = undefined
}, 500)
const doc = untrack(() => selectedDocument)
@@ -688,10 +906,53 @@
}
function listener(e: MessageEvent) {
// Two children speak to us now: the UI Builder iframe (source editor)
// and the preview iframe (rendered user app). Gate by source so they
// can't be confused or spoofed.
const fromUiBuilder = e.source === iframe?.contentWindow
const fromPreview = e.source === previewIframe?.contentWindow
if (!fromUiBuilder && !fromPreview) return
// Build output: UI Builder finished bundling. Cache it and forward to
// the preview iframe so it renders the new app.
if (fromUiBuilder && e.data.type === 'preview') {
lastBuild = { css: e.data.css, js: e.data.js }
previewIframe?.contentWindow?.postMessage(
{ type: 'preview', css: e.data.css, js: e.data.js },
'*'
)
return
}
// Build/bundler logs from the UI Builder iframe — rendered as an
// overlay inside the preview pane (see the panel below). Two
// shapes are accepted: a full snapshot (`setLogs`) for backwards
// compat, and an incremental delta (`appendLogs`) used during
// heavy bundler activity to avoid O(n²) postMessage traffic.
if (fromUiBuilder && e.data.type === 'setLogs') {
logs = String(e.data.logs ?? '')
return
}
if (fromUiBuilder && e.data.type === 'appendLogs') {
logs += String(e.data.delta ?? '')
return
}
// Inspector events come exclusively from the preview iframe.
if (fromPreview && e.data.type === 'inspectorSelect') {
inspectorElement = e.data.element as InspectorElementInfo
inspectorEnabled = false
return
}
if (fromPreview && e.data.type === 'inspectorClear') {
inspectorElement = undefined
return
}
// Everything below this point is editor metadata from the UI Builder.
if (!fromUiBuilder) return
if (e.data.type === 'setFiles') {
// Ignore setFiles from the iframe while it's reloading (e.g. theme switch);
// the iframe boots with its default template and would otherwise clobber the user's files.
if (suppressIframeSetFiles) return
// Normalize Windows-style path separators to Linux-style
const normalizedFiles = normalizeFilePaths(e.data.files)
// Only mark pending changes if files actually changed (ignore echo from setFilesInIframe)
@@ -707,12 +968,20 @@
if (suppressSetActiveDocument) return
// Normalize Windows-style path separators to Linux-style
selectedDocument = e.data.path?.replace(/\\/g, '/')
} else if (e.data.type === 'inspectorSelect') {
// Handle inspector element selection from the iframe preview
inspectorElement = e.data.element as InspectorElementInfo
} else if (e.data.type === 'inspectorClear') {
// Clear the inspector element when user dismisses the selection
inspectorElement = undefined
// If VS Code switched to a file we don't have a tab for (e.g. via
// the file explorer's reveal-in-editor, or our own auto-open of
// the main app file at boot), backfill a tab.
if (selectedDocument) {
const id = fileTabId(selectedDocument)
if (!tabs.some((t) => t.id === id)) {
ensureFileTab(selectedDocument)
// Don't auto-activate — the user's tab choice wins.
// But if no file tab is currently active, fall in line.
if (activeTabKind === 'preview' && tabs.length === 2) {
activateTab(id)
}
}
}
} else if (e.data.type === 'editorSelection') {
// Handle code selection from the iframe editor
const selection = e.data.selection
@@ -752,30 +1021,67 @@
}
let darkMode: boolean = $state(false)
// Host's computed `text-xs` size in px. Windmill bumps :root to 18px at
// ≥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)
// text-xs is 0.75rem
editorFontSize = rootPx * 0.75
}
$effect(() => {
recomputeEditorFontSize()
const onResize = () => recomputeEditorFontSize()
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
})
$effect(() => {
iframe?.addEventListener('load', () => {
iframeLoaded = true
})
})
$effect(() => {
// Toggling dark mode changes the iframe src, causing it to reload.
// Reset iframeLoaded so the populate effect refires after the new load,
// and suppress the iframe's initial setFiles (default template) until then.
void darkMode
untrack(() => {
if (iframe && iframeLoaded) {
iframeLoaded = false
suppressIframeSetFiles = true
// Cancel any pending clear from a prior reload — otherwise on rapid
// toggles the previous timer can fire mid-reload and drop suppression
// before the iframe has finished booting.
if (suppressTimer !== undefined) {
clearTimeout(suppressTimer)
suppressTimer = undefined
}
previewIframe?.addEventListener('load', () => {
previewIframeLoaded = true
// Replay the last build so the preview repopulates without
// waiting for the user to trigger another bundle.
if (lastBuild) {
previewIframe?.contentWindow?.postMessage(
{ type: 'preview', css: lastBuild.css, js: lastBuild.js },
'*'
)
}
// Escape inside the preview exits inspect mode — the keydown fires in
// the iframe's document, so the parent window listener can't see it.
// We also want Escape to dismiss a lingering green "selected" overlay
// after the user picked an element (which auto-disables hover).
previewIframe?.contentWindow?.addEventListener(
'keydown',
(e) => {
if (e.key === 'Escape' && (inspectorEnabled || inspectorElement)) {
disableInspector()
}
},
true
)
})
})
$effect(() => {
// 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 }, '*')
}
if (previewIframe && previewIframeLoaded) {
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 }, '*')
}
})
$effect(() => {
iframe && iframeLoaded && files && populateFiles()
})
@@ -795,20 +1101,15 @@
function clearInspectorSelection() {
inspectorElement = undefined
iframe?.contentWindow?.postMessage({ type: 'inspectorClear' }, '*')
// Inspector lives in the preview iframe, so clear its overlay there.
previewIframe?.contentWindow?.postMessage({ type: 'inspectorClear' }, '*')
}
function handleSelectFile(path: string) {
console.log('event Select file:', path)
selectedRunnable = undefined
// Inspector is cleared by the $effect watching selection changes
iframe?.contentWindow?.postMessage(
{
type: 'selectFile',
path: path
},
'*'
)
// Adding the tab activates it; activateTab posts the selectFile message
// to the UI Builder iframe and clears any selected runnable.
const id = ensureFileTab(path)
activateTab(id)
}
// Track previous values for change detection
@@ -827,6 +1128,50 @@
}
})
// Mirror sidebar runnable selection into the tab system. When the user
// picks a runnable from the sidebar, `selectedRunnable` flips via
// `bind:selectedRunnable`; ensure a tab for it exists and is active.
$effect(() => {
const key = selectedRunnable
if (!key) return
const id = runnableTabId(key)
untrack(() => {
if (!tabs.some((t) => t.id === id)) ensureRunnableTab(key)
if (activeTabId !== id) activeTabId = id
})
})
// Open a default file on mount (boots the iframe; avoids a blank preview).
// Layout isn't persisted — each open starts fresh in split mode.
onMount(() => {
if (tabs.length === 1) {
const def = pickDefaultFile(files)
if (def) activateTab(ensureFileTab(def))
}
})
// Drop tabs whose file/runnable no longer exists.
$effect(() => {
void files
void runnables
untrack(() => {
const filesSet = files ?? {}
const runnablesSet = runnables ?? {}
const stale = tabs.filter((t) => {
if (t.id.startsWith(FILE_PREFIX)) {
const fp = t.id.slice(FILE_PREFIX.length)
return filesSet[fp] === undefined
}
if (t.id.startsWith(RUNNABLE_PREFIX)) {
const k = t.id.slice(RUNNABLE_PREFIX.length)
return runnablesSet[k] === undefined
}
return false
})
for (const t of stale) closeTab(t.id)
})
})
function handleUndo() {
// Create a snapshot if we're at the latest position with pending changes
if (historyManager.needsSnapshotBeforeNav) {
@@ -886,6 +1231,35 @@
}
}
function disableInspector() {
// Picking an element auto-clears `inspectorEnabled`, so Escape after a
// pick gets here with hover already off but the green selection still
// up. Bail only when there is genuinely nothing to dismiss.
if (!inspectorEnabled && !inspectorElement) return
inspectorEnabled = false
// `inspectorDisable` only stops hover/click; the green "selected"
// overlay from a prior pick persists until we explicitly clear it.
// Escape should reset both, so the iframe goes back to its idle look.
previewIframe?.contentWindow?.postMessage({ type: 'inspectorDisable' }, '*')
previewIframe?.contentWindow?.postMessage({ type: 'inspectorClear' }, '*')
inspectorElement = undefined
}
// Escape exits inspect mode. We listen in the capture phase because a global
// handler swallows Escape before it bubbles to <svelte:window>. The preview
// iframe (separate document) is covered by its own listener on load.
$effect(() => {
const onEscapeCapture = (e: KeyboardEvent) => {
if (e.key === 'Escape' && (inspectorEnabled || inspectorElement)) {
disableInspector()
e.stopImmediatePropagation()
e.preventDefault()
}
}
window.addEventListener('keydown', onEscapeCapture, true)
return () => window.removeEventListener('keydown', onEscapeCapture, true)
})
function handleKeydown(e: KeyboardEvent) {
// Skip when typing in an input, textarea, or Monaco editor.
const classes = (e.target as HTMLElement | null)?.className
@@ -917,7 +1291,7 @@
<RawAppBackgroundRunner
workspace={$workspaceStore ?? ''}
editor
{iframe}
iframe={previewIframe}
bind:jobs
bind:jobsById
{runnables}
@@ -959,97 +1333,290 @@
onApply={handleYamlApply}
/>
<Splitpanes id="o2" class="grow min-h-0 border-t">
{#if !sidebarCollapsed.val}
<Pane bind:size={sidebarPanelSize} maxSize={20} class="h-full overflow-y-auto relative">
<RawAppSidebar
bind:files={
() => files,
(newFiles) => {
files = newFiles
setFilesInIframe(newFiles ?? {})
<div bind:clientWidth={splitContainerWidth} class="grow min-h-0 flex flex-col">
<Splitpanes id="o2" class="grow min-h-0 border-t">
{#if !sidebarCollapsed.val}
<Pane
bind:size={() => sidebarPanelSize, (v) => (rawSidebarSize = v)}
minSize={sidebarMinPercent}
class="h-full overflow-y-auto relative"
>
<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>
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
bars mirror the FULL tab list — whichever pane is visible
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, (v) => rememberPaneDrag(v)} 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'}">
{#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>
</Pane>
<Pane bind:size={() => paneBRightSize, (v) => rememberPaneDrag(100 - v)} 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) {
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
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>
<div bind:this={logsDiv} class="logs-scroll grow w-full overflow-auto">
{#if !logsCollapsed}
<pre>{logs}</pre>
{/if}
</div>
</div>
{/if}
</div>
</Pane>
</Splitpanes>
</div>
</Pane>
{/if}
<Pane>
<div class="h-full w-full relative">
<iframe
bind:this={iframe}
title="UI builder"
style="display: {selectedRunnable == undefined ? 'block' : 'none'}"
src="/ui_builder/index.html?dark={darkMode}"
class="w-full h-full"
></iframe>
{#if selectedRunnable !== undefined}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="flex h-full w-full">
<RawAppInlineScriptsPanel
appPath={path}
{selectedRunnable}
bind:runnables
onSelectionChange={(selection) => {
console.log('handle selection', 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
}
}
}}
/>
</div>
{/if}
</div>
<!-- <div class="bg-red-400 h-full w-full" /> -->
</Pane>
</Splitpanes>
</Splitpanes>
</div>
</div>
<style>
/* Remove the splitter from the inner content-area Splitpanes when we're
not actually in split-with-preview mode (one pane is at 0%). The user
uses the explicit Split toggle in the tab bar to flip modes; a
visible-but-non-functional drag handle would be confusing. We use
`display: none` rather than `width: 0` because svelte-splitpanes' own
splitter-width rule otherwise wins and leaves a 1px sliver beside the
preview content. */
:global(.tabs-content-single .splitpanes__splitter) {
display: none;
}
/* Logs overlay scrollbar — small, themed, matching the previous in-iframe
panel's look. */
.logs-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.logs-scroll::-webkit-scrollbar-track {
background: rgb(var(--color-surface-sunken));
}
.logs-scroll::-webkit-scrollbar-thumb {
background: rgb(var(--color-surface-secondary));
border-radius: 4px;
}
.logs-scroll::-webkit-scrollbar-thumb:hover {
background: rgb(var(--color-border-light));
}
</style>
@@ -0,0 +1,9 @@
/**
* `svelte-splitpanes` only takes percentages, so convert a pixel `minSize`
* threshold into a % of the container's current width (pair with
* `bind:clientWidth`). Capped at `cap`%; returns 0 until the width is known.
*/
export function paneMinPercent(containerWidth: number, minPx: number, cap: number = 80): number {
if (containerWidth <= 0) return 0
return Math.min(cap, (minPx / containerWidth) * 100)
}