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