fix(frontend): improve raw app history (#7625)
* fix raw app header overflow * update ui-builder hash * Make monaco default size match brand guidelines * nit * Move run button to test panel * wip improve history * add current checkout point * fix logic to switch wetween history state * improve history visualisation * improve animations * nit * remove test page * fix timing issue when selecty history entries * update ui_builder hash * remove dev file * nit * revert setting editor font to 13.5 px * update ui builder --------- Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
This commit is contained in:
@@ -20,7 +20,7 @@ console.log('Running postinstall for root project');
|
||||
|
||||
import { x } from 'tar'
|
||||
|
||||
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-c37c2f6.tar.gz'
|
||||
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-b0e4676.tar.gz'
|
||||
const outputTarPath = path.join(process.cwd(), 'ui_builder.tar.gz')
|
||||
const extractTo = path.join(process.cwd(), 'static/ui_builder/')
|
||||
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { Button, ButtonType } from '$lib/components/common'
|
||||
import { CornerDownLeft, Loader2 } from 'lucide-svelte'
|
||||
|
||||
export let isLoading
|
||||
export let hideShortcut = false
|
||||
export let onRun: () => Promise<void>
|
||||
export let onCancel: () => Promise<void>
|
||||
interface Props {
|
||||
isLoading: boolean
|
||||
hideShortcut?: boolean
|
||||
onRun: () => Promise<void>
|
||||
onCancel: () => Promise<void>
|
||||
size?: ButtonType.UnifiedSize
|
||||
}
|
||||
|
||||
let { isLoading, hideShortcut = false, onRun, onCancel, size = 'sm' }: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if !isLoading}
|
||||
<Button
|
||||
loading={isLoading}
|
||||
size="sm"
|
||||
unifiedSize={size}
|
||||
variant="accent"
|
||||
btnClasses="!px-2 !py-1"
|
||||
on:click={() => onRun()}
|
||||
onClick={() => onRun()}
|
||||
shortCut={{ Icon: CornerDownLeft, hide: hideShortcut }}
|
||||
>
|
||||
Run
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="accent"
|
||||
destructive
|
||||
btnClasses="!px-2 !py-1"
|
||||
on:click={() => onCancel()}
|
||||
>
|
||||
<Button unifiedSize={size} variant="accent" destructive onClick={() => onCancel()}>
|
||||
<Loader2 size={14} class="animate-spin mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -297,7 +297,7 @@
|
||||
}
|
||||
})
|
||||
|
||||
let fontSize = $derived(small ? 12 : 14)
|
||||
let fontSize = $derived(small ? 12 : 13.5)
|
||||
|
||||
async function loadMonaco() {
|
||||
setMonacoJsonOptions()
|
||||
|
||||
@@ -190,6 +190,20 @@
|
||||
)
|
||||
}
|
||||
|
||||
function setFilesAndSelectInIframe(newFiles: Record<string, string>, pathToSelect: string) {
|
||||
const files = Object.fromEntries(
|
||||
Object.entries(newFiles).filter(([path, _]) => !path.endsWith('/'))
|
||||
)
|
||||
iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'setFilesAndSelect',
|
||||
files: files,
|
||||
pathToSelect: pathToSelect
|
||||
},
|
||||
'*'
|
||||
)
|
||||
}
|
||||
|
||||
function populateRunnables() {
|
||||
iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
@@ -298,9 +312,9 @@
|
||||
files = {}
|
||||
}
|
||||
files[path] = content
|
||||
setFilesInIframe(files)
|
||||
selectedDocument = path
|
||||
handleSelectFile(path)
|
||||
// Use combined setFilesAndSelect to avoid race condition
|
||||
setFilesAndSelectInIframe(files, path)
|
||||
return lint()
|
||||
},
|
||||
deleteFrontendFile: (path) => {
|
||||
@@ -733,9 +747,10 @@
|
||||
}
|
||||
|
||||
function handleHistorySelect(id: number) {
|
||||
// Create a snapshot if we have pending changes before navigating
|
||||
if (historyManager.needsSnapshotBeforeNav) {
|
||||
historyManager.manualSnapshot(files ?? {}, runnables, summary, data)
|
||||
// Save current state temporarily (not as a snapshot) when navigating to history
|
||||
// Only if we're currently at the "current" state (not already viewing history)
|
||||
if (historyManager.selectedEntryId === undefined) {
|
||||
historyManager.saveTemporaryCurrentState(files ?? {}, runnables, summary, data)
|
||||
}
|
||||
|
||||
const entry = historyManager.selectEntry(id)
|
||||
@@ -756,19 +771,15 @@
|
||||
summary = entry.summary
|
||||
data = structuredClone($state.snapshot(entry.data))
|
||||
|
||||
setFilesInIframe(entry.files)
|
||||
populateRunnables()
|
||||
|
||||
// Re-select the current document if it exists in the new files
|
||||
// If there's a selected document that exists in the new files, use the combined message
|
||||
if (selectedDocument && entry.files[selectedDocument] !== undefined) {
|
||||
iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'selectFile',
|
||||
path: selectedDocument
|
||||
},
|
||||
'*'
|
||||
)
|
||||
// Use combined setFilesAndSelect message to avoid race condition
|
||||
setFilesAndSelectInIframe(entry.files, selectedDocument)
|
||||
} else {
|
||||
// Otherwise just set files normally
|
||||
setFilesInIframe(entry.files)
|
||||
}
|
||||
populateRunnables()
|
||||
} catch (error) {
|
||||
console.error('Failed to apply entry:', error)
|
||||
sendUserToast('Failed to apply entry: ' + (error as Error).message, true)
|
||||
@@ -819,8 +830,8 @@
|
||||
onRedo={handleRedo}
|
||||
/>
|
||||
|
||||
<Splitpanes id="o2" class="grow">
|
||||
<Pane bind:size={sidebarPanelSize} maxSize={20}>
|
||||
<Splitpanes id="o2" class="grow min-h-0">
|
||||
<Pane bind:size={sidebarPanelSize} maxSize={20} class="h-full overflow-y-auto">
|
||||
<RawAppSidebar
|
||||
bind:files={
|
||||
() => files,
|
||||
@@ -855,6 +866,15 @@
|
||||
{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)
|
||||
}}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script lang="ts">
|
||||
import type { HistoryEntry, HistoryBranch } from './RawAppHistoryManager.svelte'
|
||||
import { classNames, displayDate } from '$lib/utils'
|
||||
import { GitBranch } from 'lucide-svelte'
|
||||
import { displayDate } from '$lib/utils'
|
||||
import { Circle, CircleDot, CircleDotDashed, CircleDashed } from 'lucide-svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
interface Props {
|
||||
entries: HistoryEntry[]
|
||||
branches: HistoryBranch[]
|
||||
selectedId: number | undefined
|
||||
onSelect: (id: number) => void
|
||||
onSelectCurrent?: () => void
|
||||
}
|
||||
|
||||
let { entries, branches, selectedId, onSelect }: Props = $props()
|
||||
let { entries, branches, selectedId, onSelect, onSelectCurrent }: Props = $props()
|
||||
|
||||
// Constants for layout
|
||||
const LINE_X = 8 // X position of main timeline line (center of dots)
|
||||
const BRANCH_OFFSET_X = 45 // How far branches are offset horizontally
|
||||
const ENTRY_HEIGHT = 28 // Height of each entry
|
||||
const CURRENT_HEIGHT = 32 // Height of current state button
|
||||
|
||||
// Build a map of fork points to their branches for rendering
|
||||
const branchesByForkPoint = $derived(
|
||||
@@ -26,98 +34,298 @@
|
||||
)
|
||||
)
|
||||
|
||||
// Entries in reverse order (newest first)
|
||||
// Entries in reverse order (newest first) for display
|
||||
const reversedEntries = $derived(entries.slice().reverse())
|
||||
|
||||
// Calculate positions for rendering
|
||||
const entryPositions = $derived.by(() => {
|
||||
const positions: Record<number, { x: number; y: number; index: number }> = {}
|
||||
|
||||
let currentY = CURRENT_HEIGHT + 12 // Start below current button
|
||||
|
||||
reversedEntries.forEach((entry, index) => {
|
||||
// For each entry, calculate space needed for branches above it
|
||||
const entryBranches = branchesByForkPoint[entry.id] ?? []
|
||||
const branchSpaceAbove = entryBranches.reduce((total, branch) => {
|
||||
return total + branch.entries.length * ENTRY_HEIGHT
|
||||
}, 0)
|
||||
|
||||
// Add space for branches above this entry
|
||||
currentY += branchSpaceAbove
|
||||
|
||||
// Position this entry
|
||||
positions[entry.id] = { x: LINE_X, y: currentY, index }
|
||||
|
||||
// Move to next entry position
|
||||
currentY += ENTRY_HEIGHT
|
||||
})
|
||||
|
||||
return positions
|
||||
})
|
||||
|
||||
// Calculate total height needed for the container
|
||||
const totalHeight = $derived.by(() => {
|
||||
const mainTimelineMaxY = Object.values(entryPositions).reduce(
|
||||
(max, pos) => Math.max(max, pos.y),
|
||||
0
|
||||
)
|
||||
const branchMaxY = Object.values(branchPositions)
|
||||
.flatMap((groups) => Object.values(groups).flatMap((nodes) => nodes.map((node) => node.y)))
|
||||
.reduce((max, y) => Math.max(max, y), 0)
|
||||
return Math.max(mainTimelineMaxY, branchMaxY, 150) + 20 // Add padding at bottom
|
||||
})
|
||||
|
||||
// Calculate branch positions
|
||||
const branchPositions = $derived.by(() => {
|
||||
const positions: Record<
|
||||
number,
|
||||
Record<number, Array<{ id: number; x: number; y: number }>>
|
||||
> = {}
|
||||
|
||||
Object.entries(branchesByForkPoint).forEach(([forkPointId, forkBranches]) => {
|
||||
const forkPoint = entryPositions[Number(forkPointId)]
|
||||
if (!forkPoint) return
|
||||
|
||||
positions[Number(forkPointId)] = {}
|
||||
|
||||
forkBranches.forEach((branch) => {
|
||||
positions[Number(forkPointId)][branch.id] = []
|
||||
|
||||
// Branch flows from fork point upward (toward newer/current)
|
||||
// Start just above the fork point and continue upward
|
||||
// Oldest branch entry is closest to fork point, newest is furthest
|
||||
let currentY = forkPoint.y - ENTRY_HEIGHT
|
||||
|
||||
branch.entries.forEach((entry) => {
|
||||
positions[Number(forkPointId)][branch.id].push({
|
||||
id: entry.id,
|
||||
x: LINE_X + BRANCH_OFFSET_X,
|
||||
y: currentY
|
||||
})
|
||||
currentY -= ENTRY_HEIGHT // Move upward (decreasing Y)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return positions
|
||||
})
|
||||
|
||||
// Helper to create SVG path for branch connection
|
||||
function createBranchPath(startX: number, startY: number, endX: number, endY: number): string {
|
||||
// Control points are vertically offset to create vertical tangents
|
||||
const controlOffset = Math.abs(startY - endY) * 0.85 // 85% of vertical distance for very intense curve
|
||||
// Both tangents point upward (toward smaller Y values)
|
||||
// Start control point: above the start point
|
||||
// End control point: above the end point (since endY < startY, we add to go further up)
|
||||
return `M ${startX} ${startY} C ${startX} ${startY - controlOffset}, ${endX} ${endY + controlOffset}, ${endX} ${endY}`
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet timelineButton(
|
||||
label: string,
|
||||
timestamp: string | null,
|
||||
isSelected: boolean,
|
||||
onClick: () => void,
|
||||
position: { x: number; y: number },
|
||||
type: 'current' | 'main' | 'branch' = 'main'
|
||||
)}
|
||||
{@const colors = {
|
||||
current: {
|
||||
selected: 'text-accent',
|
||||
default: 'text-primary',
|
||||
textSelected: 'text-accent',
|
||||
bgSelected: 'bg-surface-accent-selected'
|
||||
},
|
||||
main: {
|
||||
selected: 'text-accent',
|
||||
default: 'text-primary',
|
||||
textSelected: 'text-accent',
|
||||
bgSelected: 'bg-surface-accent-selected'
|
||||
},
|
||||
branch: {
|
||||
selected: 'text-amber-500 dark:text-amber-400',
|
||||
default: 'text-secondary',
|
||||
textSelected: 'text-amber-600 dark:text-amber-400',
|
||||
bgSelected: 'bg-amber-50 dark:bg-amber-900/20'
|
||||
}
|
||||
}}
|
||||
{@const color = colors[type]}
|
||||
|
||||
<button
|
||||
onclick={onClick}
|
||||
aria-label={timestamp ? `Snapshot from ${displayDate(timestamp, true, false)}` : label}
|
||||
aria-current={isSelected ? 'true' : 'false'}
|
||||
class={'absolute flex items-center gap-1 transition-all duration-200 animate-fadeIn'}
|
||||
style="left: {position.x - 6}px; top: {position.y - 6}px;"
|
||||
>
|
||||
{#if isSelected}
|
||||
{#if type === 'current'}
|
||||
<CircleDotDashed size={12} class="bg-surface {color.selected}" />
|
||||
{:else}
|
||||
<CircleDot size={12} class="bg-surface {color.selected}" />
|
||||
{/if}
|
||||
{:else if type === 'current'}
|
||||
<CircleDashed size={12} class="bg-surface {color.default}" />
|
||||
{:else}
|
||||
<Circle size={12} class="bg-surface {color.default}" />
|
||||
{/if}
|
||||
<span
|
||||
class={twMerge(
|
||||
'text-2xs truncate px-2 py-1 rounded-md',
|
||||
type === 'current' ? 'font-semibold text-emphasis' : 'font-normal',
|
||||
isSelected ? color.textSelected : 'text-tertiary',
|
||||
isSelected ? color.bgSelected : 'hover:bg-surface-hover'
|
||||
)}
|
||||
>
|
||||
{timestamp ? displayDate(timestamp, true, false) : label}
|
||||
</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#if entries.length === 0}
|
||||
<div class="text-tertiary py-2 text-center text-2xs">
|
||||
No snapshots yet. Auto-saved every 5 min.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative w-full">
|
||||
<!-- Timeline line -->
|
||||
<div class="absolute left-[0.95rem] top-2 bottom-2 w-px bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div
|
||||
class="relative w-full"
|
||||
style="height: {totalHeight}px; transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);"
|
||||
role="list"
|
||||
aria-label="History timeline"
|
||||
>
|
||||
<!-- SVG for lines and connections -->
|
||||
<svg class="absolute inset-0 pointer-events-none" style="width: 100%; height: 100%;">
|
||||
<!-- Always show the main line if there are entries -->
|
||||
{#if reversedEntries.length > 0}
|
||||
{@const mainTimelineMaxY = Math.max(...Object.values(entryPositions).map((pos) => pos.y))}
|
||||
{@const branchMaxY = Object.values(branchPositions)
|
||||
.flatMap((groups) =>
|
||||
Object.values(groups).flatMap((nodes) => nodes.map((node) => node.y))
|
||||
)
|
||||
.reduce((max, y) => Math.max(max, y), 0)}
|
||||
{@const maxY = Math.max(mainTimelineMaxY, branchMaxY)}
|
||||
<line
|
||||
x1={LINE_X}
|
||||
y1={8}
|
||||
x2={LINE_X}
|
||||
y2={maxY + 8}
|
||||
stroke="currentColor"
|
||||
stroke-width="1"
|
||||
opacity="1"
|
||||
class="text-primary"
|
||||
style="transition: y2 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#each reversedEntries as entry, i (entry.id)}
|
||||
{@const isSelected = selectedId === entry.id}
|
||||
{@const isFirst = i === 0}
|
||||
{@const entryBranches = branchesByForkPoint[entry.id] ?? []}
|
||||
<!-- Branch connection lines -->
|
||||
{#each Object.entries(branchPositions) as [forkPointId, branchGroups] (forkPointId)}
|
||||
{@const forkPoint = entryPositions[Number(forkPointId)]}
|
||||
{#if forkPoint}
|
||||
{#each Object.entries(branchGroups) as [branchId, branchNodes] (branchId)}
|
||||
{#if branchNodes.length > 0}
|
||||
<!-- Connection from fork point to first branch entry -->
|
||||
<path
|
||||
d={createBranchPath(forkPoint.x, forkPoint.y, branchNodes[0].x, branchNodes[0].y)}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
fill="none"
|
||||
stroke-dasharray="4 2"
|
||||
opacity="0.6"
|
||||
class="text-secondary transition-all duration-300"
|
||||
/>
|
||||
|
||||
<!-- Render branches ABOVE their fork point (newest first within branch) -->
|
||||
{#each entryBranches as branch (branch.id)}
|
||||
<div
|
||||
class="ml-4 relative border-l border-dashed border-gray-300 dark:border-gray-600 pl-2 my-1"
|
||||
>
|
||||
<div class="absolute left-3 bottom-2 text-tertiary">
|
||||
<GitBranch size={10} />
|
||||
</div>
|
||||
<!-- Branch entries in reverse order (newest first) -->
|
||||
{#each branch.entries.slice().reverse() as branchEntry (branchEntry.id)}
|
||||
{@const isBranchSelected = selectedId === branchEntry.id}
|
||||
<button
|
||||
onclick={() => onSelect(branchEntry.id)}
|
||||
class={classNames(
|
||||
'relative flex items-center gap-2 py-1 pr-1 pl-2 w-full text-left rounded transition-colors',
|
||||
'hover:bg-surface-hover',
|
||||
isBranchSelected ? 'bg-amber-50 dark:bg-amber-900/20' : ''
|
||||
)}
|
||||
>
|
||||
<!-- Branch dot -->
|
||||
<div
|
||||
class={classNames(
|
||||
'w-1 h-1 rounded-full',
|
||||
isBranchSelected
|
||||
? 'bg-amber-500 dark:bg-amber-400'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
)}
|
||||
></div>
|
||||
<span
|
||||
class={classNames(
|
||||
'text-2xs truncate',
|
||||
isBranchSelected
|
||||
? 'text-amber-600 dark:text-amber-400 font-medium'
|
||||
: 'text-tertiary'
|
||||
)}
|
||||
>
|
||||
{displayDate(branchEntry.timestamp.toISOString(), true, false)}
|
||||
</span>
|
||||
</button>
|
||||
<!-- Vertical line connecting branch entries within this branch -->
|
||||
{#if branchNodes.length > 1}
|
||||
{@const firstNode = branchNodes[0]}
|
||||
{@const lastNode = branchNodes[branchNodes.length - 1]}
|
||||
<line
|
||||
x1={firstNode.x}
|
||||
y1={firstNode.y}
|
||||
x2={lastNode.x}
|
||||
y2={lastNode.y}
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="4 2"
|
||||
opacity="0.6"
|
||||
class="text-secondary"
|
||||
style="transition: y1 0.3s cubic-bezier(0.4, 0, 0.2, 1), y2 0.3s cubic-bezier(0.4, 0, 0.2, 1)"
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
|
||||
<!-- Main timeline entry -->
|
||||
<button
|
||||
onclick={() => onSelect(entry.id)}
|
||||
class={classNames(
|
||||
'relative flex items-center gap-2 py-1 pr-1 pl-3 w-full text-left rounded transition-colors',
|
||||
'hover:bg-surface-hover',
|
||||
isSelected ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
)}
|
||||
>
|
||||
<!-- Timeline dot -->
|
||||
<div
|
||||
class={classNames(
|
||||
'absolute left-0 w-1.5 h-1.5 rounded-full border-[1.5px] bg-surface',
|
||||
isSelected
|
||||
? 'border-blue-500 dark:border-blue-400'
|
||||
: 'border-gray-300 dark:border-gray-600'
|
||||
)}
|
||||
></div>
|
||||
<!-- Interactive elements -->
|
||||
<div class="relative">
|
||||
<!-- Current Working State -->
|
||||
{@render timelineButton(
|
||||
'Current',
|
||||
null,
|
||||
selectedId === undefined,
|
||||
() => onSelectCurrent?.(),
|
||||
{ x: LINE_X, y: 6 },
|
||||
'current'
|
||||
)}
|
||||
|
||||
<span
|
||||
class={classNames(
|
||||
'text-2xs truncate',
|
||||
isSelected ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-secondary'
|
||||
<!-- Main timeline entries -->
|
||||
{#each reversedEntries as entry (entry.id)}
|
||||
{@const pos = entryPositions[entry.id]}
|
||||
{@const isSelected = selectedId === entry.id}
|
||||
{@const entryBranches = branchesByForkPoint[entry.id] ?? []}
|
||||
|
||||
{#if pos}
|
||||
<!-- Main entry button -->
|
||||
{@render timelineButton(
|
||||
'',
|
||||
entry.timestamp.toISOString(),
|
||||
isSelected,
|
||||
() => onSelect(entry.id),
|
||||
pos,
|
||||
'main'
|
||||
)}
|
||||
>
|
||||
{#if isFirst && !isSelected}
|
||||
<span class="text-tertiary">Latest · </span>
|
||||
|
||||
<!-- Branch entries -->
|
||||
{#if entryBranches.length > 0}
|
||||
{#each Object.values(branchPositions[entry.id] ?? {}) as branchNodes}
|
||||
{#each branchNodes as branchNode (branchNode.id)}
|
||||
{@const branchEntry = branches
|
||||
.flatMap((b) => b.entries)
|
||||
.find((e) => e.id === branchNode.id)}
|
||||
{@const isBranchSelected = selectedId === branchNode.id}
|
||||
{#if branchEntry}
|
||||
{@render timelineButton(
|
||||
'',
|
||||
branchEntry.timestamp.toISOString(),
|
||||
isBranchSelected,
|
||||
() => onSelect(branchNode.id),
|
||||
{ x: branchNode.x, y: branchNode.y - 4 },
|
||||
'branch'
|
||||
)}
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
{/if}
|
||||
{displayDate(entry.timestamp.toISOString(), true, false)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s cubic-bezier(0.4, 0, 0.2, 1) forwards;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -70,6 +70,8 @@ export class RawAppHistoryManager {
|
||||
private branchIdCounter = $state(0)
|
||||
// Track if current state has pending changes
|
||||
private hasPendingChanges = $state(false)
|
||||
// Temporary storage for current state when viewing history
|
||||
private temporaryCurrentState = $state<HistoryEntry | undefined>(undefined)
|
||||
|
||||
// Derived state
|
||||
public readonly hasEntries = $derived(this.entries.length > 0)
|
||||
@@ -93,6 +95,9 @@ export class RawAppHistoryManager {
|
||||
this.currentIndex === -1 && this.currentBranchId === undefined && this.hasPendingChanges
|
||||
)
|
||||
|
||||
// Whether we have a temporary current state saved
|
||||
public readonly hasTemporaryState = $derived(this.temporaryCurrentState !== undefined)
|
||||
|
||||
public readonly canUndo = $derived(
|
||||
this.currentIndex > 0 ||
|
||||
(this.currentIndex === -1 && this.entries.length > 1) ||
|
||||
@@ -178,19 +183,25 @@ export class RawAppHistoryManager {
|
||||
* When making changes from a historical position, create a branch from the "future"
|
||||
*/
|
||||
markPendingChanges(): void {
|
||||
// If we're on a branch and making changes, that branch becomes main
|
||||
if (this.currentBranchId !== undefined) {
|
||||
this.promoteBranchToMain()
|
||||
}
|
||||
// If we're at a historical position on main timeline
|
||||
else if (this.currentIndex !== -1 && this.currentIndex < this.entries.length - 1) {
|
||||
this.createBranchFromFuture()
|
||||
}
|
||||
// If we're viewing history (not at current state)
|
||||
if (this.currentIndex !== -1 || this.currentBranchId !== undefined) {
|
||||
// Convert temporary current state to a real snapshot first
|
||||
this.convertTemporaryToSnapshot()
|
||||
|
||||
// Clear selection - we're now in a new unsaved state
|
||||
this.currentIndex = -1
|
||||
this.currentBranchId = undefined
|
||||
this.currentBranchEntryIndex = -1
|
||||
// If we're on a branch and making changes, that branch becomes main
|
||||
if (this.currentBranchId !== undefined) {
|
||||
this.promoteBranchToMain()
|
||||
}
|
||||
// If we're at a historical position on main timeline
|
||||
else if (this.currentIndex !== -1 && this.currentIndex < this.entries.length - 1) {
|
||||
this.createBranchFromFuture()
|
||||
}
|
||||
|
||||
// Clear selection - we're now at current state
|
||||
this.currentIndex = -1
|
||||
this.currentBranchId = undefined
|
||||
this.currentBranchEntryIndex = -1
|
||||
}
|
||||
|
||||
this.hasPendingChanges = true
|
||||
}
|
||||
@@ -341,12 +352,44 @@ export class RawAppHistoryManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear selection (go back to latest state)
|
||||
* Clear selection (go back to current state)
|
||||
*/
|
||||
clearSelection(): void {
|
||||
this.currentIndex = -1
|
||||
this.currentBranchId = undefined
|
||||
this.currentBranchEntryIndex = -1
|
||||
this.hasPendingChanges = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Save current state temporarily when viewing history
|
||||
*/
|
||||
public saveTemporaryCurrentState(
|
||||
files: Record<string, string>,
|
||||
runnables: Record<string, Runnable>,
|
||||
summary: string,
|
||||
data: RawAppData
|
||||
): void {
|
||||
this.temporaryCurrentState = this.createSnapshot(files, runnables, summary, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear temporary current state
|
||||
*/
|
||||
public getAndClearTemporaryState(): HistoryEntry | undefined {
|
||||
const temp = this.temporaryCurrentState
|
||||
this.temporaryCurrentState = undefined
|
||||
return temp
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert temporary current state to a real snapshot
|
||||
*/
|
||||
convertTemporaryToSnapshot(): void {
|
||||
if (this.temporaryCurrentState) {
|
||||
this.addSnapshot(this.temporaryCurrentState)
|
||||
this.temporaryCurrentState = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,6 +450,7 @@ export class RawAppHistoryManager {
|
||||
this.currentIndex = -1
|
||||
this.currentBranchId = undefined
|
||||
this.currentBranchEntryIndex = -1
|
||||
this.temporaryCurrentState = undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
import DiffEditor from '$lib/components/DiffEditor.svelte'
|
||||
import type { InlineScript, StaticAppInput, UserAppInput, CtxAppInput } from '../apps/inputType'
|
||||
import CacheTtlPopup from '../apps/editor/inlineScriptsPanel/CacheTtlPopup.svelte'
|
||||
import RunButton from '$lib/components/RunButton.svelte'
|
||||
import { computeFields } from '../apps/editor/inlineScriptsPanel/utils'
|
||||
import EditorBar from '../EditorBar.svelte'
|
||||
import { LanguageIcon } from '../common/languageIcons'
|
||||
@@ -40,6 +39,7 @@
|
||||
signDebugRequest,
|
||||
getDebugErrorMessage
|
||||
} from '$lib/components/debug'
|
||||
import TextInput from '../text_input/TextInput.svelte'
|
||||
|
||||
interface Props {
|
||||
inlineScript: (InlineScript & { language: ScriptLang }) | undefined
|
||||
@@ -47,9 +47,7 @@
|
||||
id: string
|
||||
fields?: Record<string, StaticAppInput | UserAppInput | CtxAppInput>
|
||||
path: string
|
||||
isLoading?: boolean
|
||||
onRun: () => Promise<void>
|
||||
onCancel: () => Promise<void>
|
||||
editor?: Editor | undefined
|
||||
lastDeployedCode?: string | undefined
|
||||
/** Called when code is selected in the editor */
|
||||
@@ -70,9 +68,7 @@
|
||||
id,
|
||||
fields = $bindable(undefined),
|
||||
path,
|
||||
isLoading = false,
|
||||
onRun,
|
||||
onCancel,
|
||||
editor = $bindable(undefined),
|
||||
lastDeployedCode,
|
||||
onSelectionChange
|
||||
@@ -170,7 +166,9 @@
|
||||
const dapServerUrl = $derived(
|
||||
getDebugServerUrl((inlineScript?.language || 'python3') as DebugLanguage)
|
||||
)
|
||||
const debugFilePath = $derived(`/tmp/script${getDebugFileExtension(inlineScript?.language ?? '')}`)
|
||||
const debugFilePath = $derived(
|
||||
`/tmp/script${getDebugFileExtension(inlineScript?.language ?? '')}`
|
||||
)
|
||||
const isDebuggableScript = $derived(isDebuggable(inlineScript?.language ?? ''))
|
||||
const showDebugPanel = $derived(
|
||||
debugMode && $debugState.connected && ($debugState.running || $debugState.stopped)
|
||||
@@ -306,7 +304,11 @@
|
||||
|
||||
let signedPayload
|
||||
try {
|
||||
signedPayload = await signDebugRequest($workspaceStore ?? '', code ?? '', inlineScript.language ?? 'python3')
|
||||
signedPayload = await signDebugRequest(
|
||||
$workspaceStore ?? '',
|
||||
code ?? '',
|
||||
inlineScript.language ?? 'python3'
|
||||
)
|
||||
debugSessionJobId = signedPayload.job_id
|
||||
} catch (signError) {
|
||||
sendUserToast(getDebugErrorMessage(signError), true)
|
||||
@@ -314,12 +316,15 @@
|
||||
}
|
||||
|
||||
// Get static args from fields
|
||||
const args = Object.entries(fields ?? {}).reduce<Record<string, unknown>>((acc, [key, obj]) => {
|
||||
if (obj.type === 'static') {
|
||||
acc[key] = obj.value
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
const args = Object.entries(fields ?? {}).reduce<Record<string, unknown>>(
|
||||
(acc, [key, obj]) => {
|
||||
if (obj.type === 'static') {
|
||||
acc[key] = obj.value
|
||||
}
|
||||
return acc
|
||||
},
|
||||
{}
|
||||
)
|
||||
|
||||
await dapClient.connect()
|
||||
await dapClient.initialize()
|
||||
@@ -597,19 +602,21 @@
|
||||
</div>
|
||||
{#if name !== undefined}
|
||||
<div class="flex flex-row gap-2 w-full items-center">
|
||||
<input
|
||||
onkeydown={stopPropagation(bubble('keydown'))}
|
||||
<TextInput
|
||||
inputProps={{
|
||||
onkeydown: () => stopPropagation(bubble('keydown')),
|
||||
placeholder: 'Inline script name'
|
||||
}}
|
||||
bind:value={name}
|
||||
placeholder="Inline script name"
|
||||
class="!text-xs !rounded-sm !shadow-none"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
title="Clear script"
|
||||
size="xs2"
|
||||
color="light"
|
||||
variant="contained"
|
||||
variant="subtle"
|
||||
aria-label="Clear script"
|
||||
destructive
|
||||
unifiedSize="sm"
|
||||
on:click={() => dispatch('delete')}
|
||||
endIcon={{ icon: Trash2 }}
|
||||
iconOnly
|
||||
@@ -622,14 +629,13 @@
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="xs2"
|
||||
unifiedSize="sm"
|
||||
on:click={async () => {
|
||||
editor?.format()
|
||||
}}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
<RunButton {isLoading} {onRun} {onCancel} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -639,7 +645,7 @@
|
||||
{editor}
|
||||
lang={inlineScript.language}
|
||||
{websocketAlive}
|
||||
iconOnly={width < 950}
|
||||
iconOnly={width < 1250}
|
||||
kind={'script'}
|
||||
template={'script'}
|
||||
on:showDiffMode={showDiffMode}
|
||||
@@ -705,7 +711,11 @@
|
||||
if (inlineScript.schema == undefined) {
|
||||
inlineScript.schema = emptySchema()
|
||||
}
|
||||
await inferInlineScriptSchema(inlineScript?.language, e.detail, inlineScript.schema)
|
||||
await inferInlineScriptSchema(
|
||||
inlineScript?.language,
|
||||
e.detail,
|
||||
inlineScript.schema
|
||||
)
|
||||
if (JSON.stringify(inlineScript.schema) != oldSchema) {
|
||||
inlineScript = inlineScript
|
||||
syncFields()
|
||||
@@ -786,12 +796,17 @@
|
||||
<Modal title="Debug Feature (Beta)" bind:open={showDebugBetaWarning}>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-800/50">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-800/50"
|
||||
>
|
||||
<AlertTriangle class="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-secondary text-sm">
|
||||
<p>The Debug feature is currently in <strong>beta</strong>. You may encounter unexpected behavior or limitations.</p>
|
||||
<p
|
||||
>The Debug feature is currently in <strong>beta</strong>. You may encounter unexpected
|
||||
behavior or limitations.</p
|
||||
>
|
||||
<p class="mt-2">By continuing, you acknowledge that this feature is experimental.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
import { DebugToolbar, DebugPanel, debugState } from '$lib/components/debug'
|
||||
import LogViewer from '$lib/components/LogViewer.svelte'
|
||||
import DisplayResult from '$lib/components/DisplayResult.svelte'
|
||||
import RunButton from '$lib/components/RunButton.svelte'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
|
||||
type RunnableWithInlineScript = RunnableWithFields & {
|
||||
@@ -67,7 +68,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
let selectedTab = $state('inputs')
|
||||
let selectedTab = $state('test')
|
||||
let args = $state({})
|
||||
|
||||
function getSchema(runnable: RunnableWithFields) {
|
||||
@@ -88,16 +89,18 @@
|
||||
let inlineScriptEditor: RawAppInlineScriptEditor | undefined = $state()
|
||||
|
||||
// Get debug state from the editor
|
||||
const editorDebugState = $derived(inlineScriptEditor?.getDebugState?.() ?? {
|
||||
debugMode: false,
|
||||
isDebuggableScript: false,
|
||||
showDebugPanel: false,
|
||||
hasDebugResult: false,
|
||||
dapClient: null,
|
||||
selectedDebugFrameId: null,
|
||||
debugSessionJobId: null,
|
||||
debugBreakpoints: new Set()
|
||||
})
|
||||
const editorDebugState = $derived(
|
||||
inlineScriptEditor?.getDebugState?.() ?? {
|
||||
debugMode: false,
|
||||
isDebuggableScript: false,
|
||||
showDebugPanel: false,
|
||||
hasDebugResult: false,
|
||||
dapClient: null,
|
||||
selectedDebugFrameId: null,
|
||||
debugSessionJobId: null,
|
||||
debugBreakpoints: new Set()
|
||||
}
|
||||
)
|
||||
|
||||
// Reactive debug state values
|
||||
const debugMode = $derived(editorDebugState.debugMode)
|
||||
@@ -190,13 +193,7 @@
|
||||
bind:inlineScript={runnable.inlineScript}
|
||||
bind:name={runnable.name}
|
||||
bind:fields={runnable.fields}
|
||||
isLoading={testIsLoading}
|
||||
onRun={testPreview}
|
||||
onCancel={async () => {
|
||||
if (jobLoader) {
|
||||
await jobLoader.cancelJob()
|
||||
}
|
||||
}}
|
||||
on:delete
|
||||
path={appPath}
|
||||
{onSelectionChange}
|
||||
@@ -221,8 +218,8 @@
|
||||
</Pane>
|
||||
<Pane size={45}>
|
||||
<Tabs bind:selected={selectedTab}>
|
||||
<Tab value="inputs" label="Inputs" />
|
||||
<Tab value="test" label="Test" />
|
||||
<Tab value="inputs" label="Inputs" />
|
||||
{#snippet content()}
|
||||
{#if selectedTab == 'inputs'}
|
||||
{#if runnable?.fields}
|
||||
@@ -275,6 +272,18 @@
|
||||
<Splitpanes horizontal class="grow">
|
||||
<Pane size={50}>
|
||||
<div class="px-2 py-3 h-full overflow-auto">
|
||||
<div class="mx-auto w-fit">
|
||||
<RunButton
|
||||
isLoading={testIsLoading}
|
||||
onRun={testPreview}
|
||||
onCancel={async () => {
|
||||
if (jobLoader) {
|
||||
await jobLoader.cancelJob()
|
||||
}
|
||||
}}
|
||||
size="md"
|
||||
/>
|
||||
</div>
|
||||
<SchemaForm
|
||||
on:keydownCmdEnter={testPreview}
|
||||
disabledArgs={Object.entries(runnable?.fields ?? {})
|
||||
@@ -307,7 +316,9 @@
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="h-full flex items-center justify-center text-sm text-tertiary">
|
||||
<div
|
||||
class="h-full flex items-center justify-center text-sm text-tertiary"
|
||||
>
|
||||
{#if $debugState.running && !$debugState.stopped}
|
||||
Running...
|
||||
{:else if $debugState.stopped}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
historyManager?: RawAppHistoryManager
|
||||
historySelectedId?: number | undefined
|
||||
onHistorySelect?: (id: number) => void
|
||||
onHistorySelectCurrent?: () => void
|
||||
onManualSnapshot?: () => void
|
||||
dataTableRefs?: DataTableRef[]
|
||||
onDataTableRefsChange?: (refs: DataTableRef[]) => void
|
||||
@@ -44,6 +45,7 @@
|
||||
historyManager,
|
||||
historySelectedId,
|
||||
onHistorySelect,
|
||||
onHistorySelectCurrent,
|
||||
onManualSnapshot,
|
||||
dataTableRefs = [],
|
||||
onDataTableRefsChange,
|
||||
@@ -444,6 +446,7 @@
|
||||
branches={historyManager.allBranches}
|
||||
selectedId={historySelectedId}
|
||||
onSelect={onHistorySelect}
|
||||
onSelectCurrent={onHistorySelectCurrent}
|
||||
/>
|
||||
</PanelSection>
|
||||
{/if}
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Auto-detect operating system
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
IS_MAC=true
|
||||
else
|
||||
IS_MAC=false
|
||||
fi
|
||||
|
||||
cd ../../windmill-code-ui-builder
|
||||
HASH=$(git rev-parse --short HEAD)
|
||||
HASH=${HASH::-1}
|
||||
|
||||
echo "Using UI Builder hash: ${HASH}"
|
||||
sed -i "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../windmill/frontend/scripts/untar_ui_builder.js
|
||||
|
||||
if [ "$IS_MAC" = true ]; then
|
||||
sed -i '' "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../windmill/frontend/scripts/untar_ui_builder.js
|
||||
else
|
||||
sed -i "s/ui_builder-[^.]*\.tar\.gz/ui_builder-${HASH}.tar.gz/" ../windmill/frontend/scripts/untar_ui_builder.js
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user