mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 16:05:43 +00:00
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:
committed by
tristantr
co-authored by
Claude Opus 4.7
parent
297dd722c9
commit
ad4368206e
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user