fix: add history to raw app builder (#7362)

* appHistory

* appHistory

* all

* all

* all

* all

* all

* all

* all

* all

* improvements
This commit is contained in:
Ruben Fiszel
2025-12-13 14:32:52 +01:00
committed by GitHub
parent 8d2ddad9e6
commit 431074d249
25 changed files with 852 additions and 159 deletions

2
backend/Cargo.lock generated
View File

@@ -14147,7 +14147,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba3beec919fbdf99d719de8eda6adae3281f8a5b71ae40431f44dc7423053d34"
dependencies = [
"loki-api",
"reqwest 0.12.24",
"reqwest 0.11.27",
"serde",
"serde_json",
"snap",

View File

@@ -756,6 +756,7 @@ pub async fn add_completed_job_error(
false,
false,
)
.warn_after_seconds(10)
.await?;
Ok(result)
}
@@ -812,6 +813,7 @@ pub async fn add_completed_job<T: Serialize + Send + Sync + ValidableJson>(
has_stream,
from_cache,
)
.warn_after_seconds(10)
})
.retry(
ConstantBuilder::default()
@@ -872,7 +874,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
) -> windmill_common::error::Result<(Option<Uuid>, i64, bool)> {
// let start = std::time::Instant::now();
let mut tx = db.begin().await?;
let mut tx = db.begin().warn_after_seconds(10).await?;
let job_id = queued_job.id;
// tracing::error!("1 {:?}", start.elapsed());
@@ -927,6 +929,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
/* $10 */ result_columns as Option<&Vec<String>>,
)
.fetch_optional(&mut *tx)
.warn_after_seconds(10)
.await
.map_err(|e| Error::internal_err(format!("Could not add completed job {job_id}: {e:#}")))?;
@@ -938,6 +941,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
job_id
)
.fetch_one(&mut *tx)
.warn_after_seconds(10)
.await
.map_err(|e| Error::internal_err(format!("Could not add completed job {job_id}: {e:#}")))?
.unwrap_or(false);
@@ -963,6 +967,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
labels as Vec<String>
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
.map_err(|e| Error::InternalErr(format!("Could not update job labels: {e:#}")))?;
}
@@ -986,6 +991,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
parent_job
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
.inspect_err(|e| {
tracing::error!(
@@ -998,7 +1004,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
// tracing::error!("Added completed job {:#?}", queued_job);
let mut _skip_downstream_error_handlers = false;
tx = delete_job(tx, &job_id).await?;
tx = delete_job(tx, &job_id).warn_after_seconds(10).await?;
// tracing::error!("3 {:?}", start.elapsed());
if queued_job.is_flow_step() {
@@ -1019,13 +1025,14 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
&queued_job.workspace_id
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await?;
if flow_is_done {
let r = sqlx::query_scalar!(
"UPDATE parallel_monitor_lock SET last_ping = now() WHERE parent_flow_id = $1 and job_id = $2 RETURNING 1",
parent_job,
&queued_job.id
).fetch_optional(&mut *tx).await?;
).fetch_optional(&mut *tx).warn_after_seconds(10).await?;
if r.is_some() {
tracing::info!(
"parallel flow iteration is done, setting parallel monitor last ping lock for job {}",
@@ -1039,8 +1046,9 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
let schedule_path = queued_job.schedule_path().unwrap();
let script_path = queued_job.runnable_path.as_ref().unwrap();
let schedule =
get_schedule_opt(&mut *tx, &queued_job.workspace_id, &schedule_path).await?;
let schedule = get_schedule_opt(&mut *tx, &queued_job.workspace_id, &schedule_path)
.warn_after_seconds(10)
.await?;
if let Some(schedule) = schedule {
#[cfg(feature = "enterprise")]
@@ -1072,6 +1080,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
&queued_job.workspace_id
)
.fetch_optional(&mut *tx)
.warn_after_seconds(10)
.await?
.flatten()
.unwrap_or(false);
@@ -1084,6 +1093,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
&script_path,
&queued_job.workspace_id,
))
.warn_after_seconds(10)
.await
{
match err {
@@ -1106,6 +1116,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
queued_job.started_at.unwrap_or(chrono::Utc::now()),
queued_job.priority,
)
.warn_after_seconds(10)
.await
{
if !success {
@@ -1122,6 +1133,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
err
),
)
.warn_after_seconds(10)
.await;
}
} else {
@@ -1148,6 +1160,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
queued_job.id.hyphenated().to_string(),
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
.map_err(|e| {
Error::internal_err(format!(
@@ -1162,6 +1175,7 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
queued_job.id,
)
.execute(&mut *tx)
.warn_after_seconds(10)
.await
{
tracing::error!(
@@ -1174,15 +1188,17 @@ async fn commit_completed_job<T: Serialize + Send + Sync + ValidableJson>(
sqlx::query!("DELETE FROM job_perms WHERE job_id = $1", job_id)
.execute(&mut *tx)
.warn_after_seconds(10)
.await?;
if !success || has_stream {
sqlx::query!("DELETE FROM job_result_stream_v2 WHERE job_id = $1", job_id)
.execute(&mut *tx)
.warn_after_seconds(10)
.await?;
}
tx.commit().await?;
tx.commit().warn_after_seconds(10).await?;
tracing::info!(
%job_id,

View File

@@ -161,6 +161,7 @@ async fn process_jc(
bench,
)
.instrument(span)
.warn_after_seconds(10)
.await;
if let Some(root_job) = root_job {
@@ -291,6 +292,7 @@ pub fn start_background_processor(
#[cfg(feature = "benchmark")]
&mut bench,
)
.warn_after_seconds(10)
.await;
if is_init_script_and_failure {
@@ -544,6 +546,7 @@ pub async fn handle_receive_completed_job(
#[cfg(feature = "benchmark")]
bench,
)
.warn_after_seconds(10)
.await;
match processed_completed_job {
@@ -807,6 +810,7 @@ pub async fn handle_job_error(
err_json.clone(),
worker_name,
)
.warn_after_seconds(10)
.await
};
@@ -850,7 +854,9 @@ pub async fn handle_job_error(
if let Err(err) = updated_flow {
if let Some(parent_job_id) = job.parent_job {
if let Ok(Some(parent_job)) =
get_mini_completed_job(&parent_job_id, &job.workspace_id, db).await
get_mini_completed_job(&parent_job_id, &job.workspace_id, db)
.warn_after_seconds(10)
.await
{
let e = json!({"message": err.to_string(), "name": "InternalErr"});
append_logs(
@@ -870,6 +876,7 @@ pub async fn handle_job_error(
false,
None,
)
.warn_after_seconds(10)
.await;
}
}

View File

@@ -20,7 +20,7 @@ console.log('Running postinstall for root project');
import { x } from 'tar'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-b4fcf00.tar.gz'
const tarUrl = 'https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev/ui_builder-6a45d08.tar.gz'
const outputTarPath = path.join(process.cwd(), 'ui_builder.tar.gz')
const extractTo = path.join(process.cwd(), 'static/ui_builder/')

View File

@@ -1,8 +1,8 @@
node ./scripts/untar_ui_builder.js
# node ./scripts/untar_ui_builder.js
mkdir ui_builder_serve || true
cp -r static/ui_builder ui_builder_serve/ui_builder || true
rm -rf static/ui_builder || true
# mkdir ui_builder_serve || true
# cp -r static/ui_builder ui_builder_serve/ui_builder || true
# rm -rf static/ui_builder || true
python3 -c "
import os
os.chdir('ui_builder_serve')

View File

@@ -274,7 +274,7 @@
{/snippet}
</Select>
{#if value && hovering}
<div class="absolute right-2 z-20">
<div class="absolute {disabled ? 'right-2' : 'right-10'} z-20">
<Button
variant="subtle"
size="xs2"

View File

@@ -1,19 +1,26 @@
<script lang="ts">
import { getDbClockNow } from '$lib/forLater'
import { displayDate } from '$lib/utils'
import { onDestroy, onMount } from 'svelte'
import { onDestroy, onMount, untrack } from 'svelte'
export let date: string
export let agoOnlyIfRecent: boolean = false
export let noDate = false
export let isRecent: boolean = true
interface Props {
date: string
agoOnlyIfRecent?: boolean
noDate?: boolean
isRecent?: boolean
}
let computedTimeAgo: string | undefined = undefined
let {
date,
agoOnlyIfRecent = false,
noDate = false,
isRecent = $bindable(true)
}: Props = $props()
let computedTimeAgo: string | undefined = $state(undefined)
let interval
$: date && computeDate()
onMount(() => {
interval = setInterval(() => {
computeDate()
@@ -73,6 +80,9 @@
}
}
}
$effect(() => {
date && untrack(() => computeDate())
})
</script>
{#if computedTimeAgo && (!agoOnlyIfRecent || isRecent)}

View File

@@ -2,7 +2,7 @@
import { Button } from '$lib/components/common'
import { WandSparkles } from 'lucide-svelte'
import { aiChatManager } from './chat/AIChatManager.svelte'
import { flowAIBtnClasses } from './chat/flow/FlowAIButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
interface Props {
label?: string
initialInput?: string
@@ -29,7 +29,7 @@
icon: WandSparkles
}}
unifiedSize="md"
btnClasses={flowAIBtnClasses('default')}
btnClasses={AIBtnClasses('default')}
on:click={onClick}
>
{label}

View File

@@ -18,7 +18,7 @@
inputBorderClass,
inputSizeClasses
} from '../text_input/TextInput.svelte'
import { flowAIBtnClasses } from './chat/flow/FlowAIButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
type PromptConfig = {
system: string
@@ -294,7 +294,7 @@ Generate a tool name for the script below:
<span
class={twMerge(
'rounded-md px-1',
flowAIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'selected')
AIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'selected')
)}
>
<span class="px-0.5 py-0.5 rounded-md text-2xs text-bold flex flex-row items-center gap-1">

View File

@@ -19,7 +19,7 @@
import FlowCopilotInputsModal from './FlowCopilotInputsModal.svelte'
import { twMerge } from 'tailwind-merge'
import { copilotInfo } from '$lib/aiStore'
import { flowAIBtnClasses } from './chat/flow/FlowAIButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
let generatedContent = $state('')
let loading = $state(false)
@@ -232,7 +232,7 @@ Only return the expression without any wrapper.`
size="xs"
variant="default"
btnClasses={twMerge(
flowAIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'default'),
AIBtnClasses(!loading && generatedContent.length > 0 ? 'green' : 'default'),
btnClass
)}
on:click={() => {

View File

@@ -19,7 +19,7 @@
import FlowCopilotInputsModal from './FlowCopilotInputsModal.svelte'
import type { Flow } from '$lib/gen'
import { twMerge } from 'tailwind-merge'
import { flowAIBtnClasses } from './chat/flow/FlowAIButton.svelte'
import { AIBtnClasses } from './chat/AIButtonStyle'
let loading = $state(false)
interface Props {
@@ -189,7 +189,7 @@ input_name2: expression2
variant="default"
btnClasses={twMerge(
!disabled &&
flowAIBtnClasses(
AIBtnClasses(
!loading && Object.keys($generatedExprs || {}).length > 0 ? 'green' : 'default'
)
)}
@@ -234,7 +234,7 @@ input_name2: expression2
<Button
size="xs"
variant="default"
btnClasses={flowAIBtnClasses('default')}
btnClasses={AIBtnClasses('default')}
nonCaptureEvent
startIcon={{
icon: Wand2

View File

@@ -1,17 +1,3 @@
<script module lang="ts">
export function flowAIBtnClasses(state: 'default' | 'selected' | 'green' = 'default') {
return twMerge(
['selected', 'default'].includes(state) ? 'text-ai !border-ai/20 hover:bg-ai/15' : '',
{
default: '',
selected: 'bg-ai/10',
green:
'bg-green-50 hover:bg-green-50 dark:bg-green-400/15 dark:hover:bg-green-400/15 text-green-800 border-green-200 dark:border-green-300/60 dark:text-green-400'
}[state]
)
}
</script>
<script lang="ts">
import { base } from '$lib/base'
import { copilotInfo } from '$lib/aiStore'
@@ -20,14 +6,13 @@
import { ExternalLink, WandSparkles } from 'lucide-svelte'
import { getModifierKey } from '$lib/utils'
import Button from '$lib/components/common/button/Button.svelte'
import { twMerge } from 'tailwind-merge'
let {
togglePanel,
selected = false
btnClasses
}: {
togglePanel: () => void
selected?: boolean
btnClasses?: string
} = $props()
</script>
@@ -72,7 +57,7 @@
onClick={onPress}
startIcon={{ icon: WandSparkles }}
iconOnly
btnClasses={flowAIBtnClasses(selected ? 'selected' : 'default')}
{btnClasses}
>
AI Panel
</Button>

View File

@@ -0,0 +1,13 @@
export function AIBtnClasses(state: 'default' | 'selected' | 'green' = 'default') {
return twMerge(
['selected', 'default'].includes(state) ? 'text-ai !border-ai/20 hover:bg-ai/15' : '',
{
default: '',
selected: 'bg-ai/10',
green:
'bg-green-50 hover:bg-green-50 dark:bg-green-400/15 dark:hover:bg-green-400/15 text-green-800 border-green-200 dark:border-green-300/60 dark:text-green-400'
}[state]
)
}
import { twMerge } from 'tailwind-merge'

View File

@@ -599,10 +599,15 @@ class AIChatManager {
throw new Error('No flow helpers found')
}
let snapshot: ExtendedOpenFlow | undefined = undefined
let snapshot:
| { type: 'flow'; value: ExtendedOpenFlow }
| { type: 'app'; value: number }
| undefined = undefined
if (this.mode === AIMode.FLOW) {
snapshot = this.flowAiChatHelpers!.getFlowAndSelectedId().flow
this.flowAiChatHelpers!.setSnapshot(snapshot)
snapshot = { type: 'flow', value: this.flowAiChatHelpers!.getFlowAndSelectedId().flow }
this.flowAiChatHelpers!.setSnapshot(snapshot.value)
} else if (this.mode === AIMode.APP) {
snapshot = { type: 'app', value: this.appAiChatHelpers!.snapshot() }
}
this.displayMessages = [

View File

@@ -89,13 +89,17 @@
{/if}
{#if message.role === 'user' && message.snapshot}
<div class="mx-2 text-sm text-primary flex flex-row items-center justify-between gap-2 mt-2">
Saved a flow snapshot
Saved {message.snapshot.type === 'flow' ? 'a flow' : 'an app'} snapshot
<Button
size="xs2"
variant="default"
on:click={() => {
if (message.snapshot) {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot)
if (message.snapshot.type === 'flow') {
aiChatManager.flowAiChatHelpers?.revertToSnapshot(message.snapshot.value)
} else if (message.snapshot.type === 'app') {
aiChatManager.appAiChatHelpers?.revertToSnapshot(message.snapshot.value)
}
}
}}
title="Revert to snapshot"

View File

@@ -80,6 +80,8 @@ export interface AppAIChatHelpers {
// Combined view
getFiles: () => AppFiles
getSelectedContext: () => SelectedContext
snapshot: () => number
revertToSnapshot: (id: number) => void
// Linting
/** Lint all frontend files and backend runnables, returns errors and warnings */
lint: () => LintResult

View File

@@ -325,7 +325,7 @@ export function buildContextString(selectedContext: ContextElement[]): string {
type BaseDisplayMessage = {
content: string
contextElements?: ContextElement[]
snapshot?: ExtendedOpenFlow
snapshot?: { type: 'flow'; value: ExtendedOpenFlow } | { type: 'app'; value: number }
}
export type UserDisplayMessage = BaseDisplayMessage & {

View File

@@ -5,9 +5,10 @@
import { Badge } from '$lib/components/common'
import { DollarSign, Settings, StickyNote } from 'lucide-svelte'
import FlowErrorHandlerItem from './FlowErrorHandlerItem.svelte'
import FlowAIButton from '$lib/components/copilot/chat/flow/FlowAIButton.svelte'
import AIButton from '$lib/components/copilot/chat/AIButton.svelte'
import Popover from '$lib/components/Popover.svelte'
import Button from '$lib/components/common/button/Button.svelte'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
interface Props {
disableSettings?: boolean
@@ -80,11 +81,11 @@
{/if}
{#if showFlowAiButton}
<Popover>
<FlowAIButton
<AIButton
togglePanel={() => {
toggleAiChat?.()
}}
selected={aiChatOpen}
btnClasses={AIBtnClasses(aiChatOpen ? 'selected' : 'default')}
/>
{#snippet text()}
Flow AI Chat

View File

@@ -6,6 +6,7 @@
import { type Policy } from '$lib/gen'
import DiffDrawer from '../DiffDrawer.svelte'
import { encodeState } from '$lib/utils'
import { deepEqual } from 'fast-equals'
// import { addWmillClient } from './utils'
import RawAppBackgroundRunner from './RawAppBackgroundRunner.svelte'
@@ -19,6 +20,8 @@
import { onMount } from 'svelte'
import type { LintResult } from '../copilot/chat/app/core'
import { rawAppLintStore } from './lintStore'
import { RawAppHistoryManager } from './RawAppHistoryManager.svelte'
import { sendUserToast } from '$lib/utils'
interface Props {
initFiles: Record<string, string>
@@ -68,6 +71,13 @@
let files: Record<string, string> | undefined = $state(initFiles)
// Initialize history manager
const historyManager = new RawAppHistoryManager({
maxEntries: 50,
autoSnapshotInterval: 5 * 60 * 1000 // 5 minutes
})
historyManager.manualSnapshot(files ?? {}, runnables, summary)
let draftTimeout: number | undefined = undefined
function saveFrontendDraft() {
draftTimeout && clearTimeout(draftTimeout)
@@ -177,8 +187,16 @@
aiChatManager.changeMode(AIMode.APP)
rawAppLintStore.enable()
// Start auto-snapshot
historyManager.startAutoSnapshot(() => ({
files: files ?? {},
runnables,
summary
}))
return () => {
rawAppLintStore.disable()
historyManager.destroy()
}
})
@@ -351,6 +369,17 @@
type: 'none',
content: ''
}
},
snapshot: () => {
// Force create snapshot for AI - it needs a restore point
return (
historyManager.manualSnapshot(files ?? {}, runnables, summary, true)?.id ??
historyManager.getId()
)
},
revertToSnapshot: (id: number) => {
console.log('reverting to snapshot', id)
handleHistorySelect(id)
}
})
})
@@ -360,7 +389,11 @@
let modules = $state({}) as Modules
function listener(e: MessageEvent) {
if (e.data.type === 'setFiles') {
files = e.data.files
// Only mark pending changes if files actually changed (ignore echo from setFilesInIframe)
if (!deepEqual(files, e.data.files)) {
files = e.data.files
historyManager.markPendingChanges()
}
} else if (e.data.type === 'getBundle') {
getBundleResolve?.(e.data.bundle)
} else if (e.data.type === 'updateModules') {
@@ -411,9 +444,77 @@
'*'
)
}
function handleUndo() {
// Create a snapshot if we're at the latest position with pending changes
if (historyManager.needsSnapshotBeforeNav) {
historyManager.manualSnapshot(files ?? {}, runnables, summary)
}
const entry = historyManager.undo()
if (entry) {
applyEntry(entry)
}
}
function handleRedo() {
const entry = historyManager.redo()
if (entry) {
applyEntry(entry)
}
}
function handleHistorySelect(id: number) {
// Create a snapshot if we have pending changes before navigating
if (historyManager.needsSnapshotBeforeNav) {
historyManager.manualSnapshot(files ?? {}, runnables, summary)
}
const entry = historyManager.selectEntry(id)
if (entry) {
applyEntry(entry)
}
}
function applyEntry(entry: {
files: Record<string, string>
runnables: Record<string, Runnable>
summary: string
}) {
try {
files = structuredClone($state.snapshot(entry.files))
runnables = structuredClone($state.snapshot(entry.runnables))
summary = entry.summary
setFilesInIframe(entry.files)
populateRunnables()
// Re-select the current document if it exists in the new files
if (selectedDocument && entry.files[selectedDocument] !== undefined) {
iframe?.contentWindow?.postMessage(
{
type: 'selectFile',
path: selectedDocument
},
'*'
)
}
} catch (error) {
console.error('Failed to apply entry:', error)
sendUserToast('Failed to apply entry: ' + (error as Error).message, true)
}
}
function handleKeydown(e: KeyboardEvent) {
// Ctrl/Cmd + Shift + H for manual snapshot
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'H') {
e.preventDefault()
historyManager.manualSnapshot(files ?? {}, runnables, summary)
}
}
</script>
<svelte:window onmessage={listener} />
<svelte:window onmessage={listener} onkeydown={handleKeydown} />
<DarkModeObserver bind:darkMode />
<RawAppBackgroundRunner
@@ -441,6 +542,10 @@
{files}
{runnables}
{getBundle}
canUndo={historyManager.canUndo}
canRedo={historyManager.canRedo}
onUndo={handleUndo}
onRedo={handleRedo}
/>
<Splitpanes id="o2" class="grow">
@@ -458,27 +563,35 @@
bind:selectedDocument
{runnables}
{modules}
{historyManager}
historySelectedId={historyManager.selectedEntryId}
onHistorySelect={handleHistorySelect}
onManualSnapshot={() => {
historyManager.manualSnapshot(files ?? {}, runnables, summary, true)
}}
></RawAppSidebar>
</Pane>
<Pane>
<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}
{initRunnablesContent}
{runnables}
/>
</div>
{/if}
<div class="h-full w-full">
<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}
{initRunnablesContent}
{runnables}
/>
</div>
{/if}
</div>
<!-- <div class="bg-red-400 h-full w-full" /> -->
</Pane>

View File

@@ -1,10 +1,21 @@
<script lang="ts">
import { Badge, Drawer, DrawerContent } from '$lib/components/common'
import Button from '$lib/components/common/button/Button.svelte'
import UndoRedo from '$lib/components/common/button/UndoRedo.svelte'
import { AppService, DraftService, type Policy } from '$lib/gen'
import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores'
import { Bug, DiffIcon, FileJson, FileUp, History, MoreVertical, Pen, Save } from 'lucide-svelte'
import {
Bug,
DiffIcon,
FileJson,
FileUp,
History,
MoreVertical,
Pen,
Save,
WandSparkles
} from 'lucide-svelte'
import { createEventDispatcher } from 'svelte'
import {
cleanValueProperties,
@@ -34,6 +45,8 @@
import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte'
import type { Runnable } from './RawAppInlineScriptRunnable.svelte'
import { updateRawAppPolicy } from './rawAppPolicy'
import { aiChatManager } from '../copilot/chat/AIChatManager.svelte'
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
// async function hash(message) {
// try {
@@ -79,6 +92,10 @@
js: string
css: string
}>
canUndo?: boolean
canRedo?: boolean
onUndo?: () => void
onRedo?: () => void
}
let {
@@ -94,7 +111,11 @@
files,
jobs = $bindable(),
jobsById = $bindable(),
getBundle
getBundle,
canUndo = false,
canRedo = false,
onUndo = undefined,
onRedo = undefined
}: Props = $props()
let newEditedPath = $state('')
@@ -708,6 +729,13 @@
>
<div class="flex flex-row gap-2 items-center">
<Summary bind:value={summary} />
<div></div>
<UndoRedo
undoProps={{ disabled: !canUndo }}
redoProps={{ disabled: !canRedo }}
on:undo={() => onUndo?.()}
on:redo={() => onRedo?.()}
/>
</div>
<div class=" flex">
@@ -769,7 +797,7 @@
>
<div class="flex flex-row gap-1 items-center">
<Bug size={14} />
<div>Debug runs</div>
<div>Jobs</div>
<div class="text-2xs text-primary"
>({jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})</div
@@ -778,6 +806,17 @@
</Button>
</div>
<AppExportButton bind:this={appExport} />
<Button
unifiedSized="sm"
color="light"
variant="default"
onClick={() => aiChatManager.toggleOpen()}
startIcon={{ icon: WandSparkles }}
iconOnly
btnClasses={AIBtnClasses('default')}
>
AI
</Button>
<Button
loading={loading.save}
startIcon={{ icon: Save }}

View File

@@ -0,0 +1,123 @@
<script lang="ts">
import type { HistoryEntry, HistoryBranch } from './RawAppHistoryManager.svelte'
import { classNames, displayDate } from '$lib/utils'
import { GitBranch } from 'lucide-svelte'
interface Props {
entries: HistoryEntry[]
branches: HistoryBranch[]
selectedId: number | undefined
onSelect: (id: number) => void
}
let { entries, branches, selectedId, onSelect }: Props = $props()
// Build a map of fork points to their branches for rendering
const branchesByForkPoint = $derived(
branches.reduce(
(acc, branch) => {
if (!acc[branch.forkPointId]) {
acc[branch.forkPointId] = []
}
acc[branch.forkPointId].push(branch)
return acc
},
{} as Record<number, HistoryBranch[]>
)
)
// Entries in reverse order (newest first)
const reversedEntries = $derived(entries.slice().reverse())
</script>
{#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>
{#each reversedEntries as entry, i (entry.id)}
{@const isSelected = selectedId === entry.id}
{@const isFirst = i === 0}
{@const entryBranches = branchesByForkPoint[entry.id] ?? []}
<!-- 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>
{/each}
</div>
{/each}
<!-- 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>
<span
class={classNames(
'text-2xs truncate',
isSelected ? 'text-blue-600 dark:text-blue-400 font-medium' : 'text-secondary'
)}
>
{#if isFirst && !isSelected}
<span class="text-tertiary">Latest · </span>
{/if}
{displayDate(entry.timestamp.toISOString(), true, false)}
</span>
</button>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,405 @@
import type { Runnable } from './utils'
import { deepEqual } from 'fast-equals'
/**
* Snapshot entry containing raw app state at a point in time
*/
export interface HistoryEntry {
id: number
timestamp: Date
files: Record<string, string>
runnables: Record<string, Runnable>
summary: string
}
/**
* A branch in the history tree
* Contains entries that diverged from a fork point
*/
export interface HistoryBranch {
id: number
forkPointId: number // ID of the entry this branch forked from
entries: HistoryEntry[]
}
/**
* Configuration for history manager
*/
export interface HistoryConfig {
maxEntries: number
autoSnapshotInterval?: number // milliseconds
}
/**
* History manager for raw apps with branching support
*
* Main timeline: The current working branch
* Branches: Preserved "futures" when navigating to historical points and making changes
*
* When selecting a historical entry and making changes:
* - Current "future" entries become a branch (forked from selected point)
* - The selected entry becomes the new "head" of main timeline
*
* When selecting an entry on a branch and making changes:
* - That branch becomes the main timeline
* - The old main timeline (from fork point onwards) becomes a branch
*/
export class RawAppHistoryManager {
// Main timeline entries
private entries = $state<HistoryEntry[]>([])
// Preserved branches (old "futures" that were branched off)
private branches = $state<HistoryBranch[]>([])
private autoSnapshotTimer: number | undefined = undefined
private getStateFn:
| (() => {
files: Record<string, string>
runnables: Record<string, Runnable>
summary: string
})
| undefined = undefined
private isCreatingSnapshot = $state(false)
// Currently selected entry index in main timeline (-1 = at latest/no selection)
private currentIndex = $state(-1)
// If viewing a branch, which branch and entry index
private currentBranchId = $state<number | undefined>(undefined)
private currentBranchEntryIndex = $state(-1)
private entryIdCounter = $state(0)
private branchIdCounter = $state(0)
// Track if current state has pending changes
private hasPendingChanges = $state(false)
// Derived state
public readonly hasEntries = $derived(this.entries.length > 0)
public readonly entryCount = $derived(this.entries.length)
public readonly allEntries = $derived(this.entries.slice())
public readonly allBranches = $derived(this.branches.slice())
public readonly canSnapshot = $derived(!this.isCreatingSnapshot)
// The ID of the currently selected entry (main timeline or branch)
public readonly selectedEntryId = $derived.by(() => {
if (this.currentBranchId !== undefined) {
const branch = this.branches.find((b) => b.id === this.currentBranchId)
return branch?.entries[this.currentBranchEntryIndex]?.id
}
if (this.currentIndex === -1) return undefined
return this.entries[this.currentIndex]?.id
})
// Whether we need to save current state before navigating
public readonly needsSnapshotBeforeNav = $derived(
this.currentIndex === -1 && this.currentBranchId === undefined && this.hasPendingChanges
)
public readonly canUndo = $derived(
this.currentIndex > 0 ||
(this.currentIndex === -1 && this.entries.length > 1) ||
(this.currentIndex === -1 && this.entries.length === 1 && this.hasPendingChanges)
)
public readonly canRedo = $derived(
this.currentIndex !== -1 && this.currentIndex < this.entries.length - 1
)
constructor(private config: HistoryConfig) {}
/**
* Create a snapshot from provided state
*/
createSnapshot(
files: Record<string, string>,
runnables: Record<string, Runnable>,
summary: string
): HistoryEntry {
return {
id: this.entryIdCounter++,
timestamp: new Date(),
files: structuredClone($state.snapshot(files)),
runnables: structuredClone($state.snapshot(runnables)),
summary: $state.snapshot(summary)
}
}
/**
* Check if state has changed since last snapshot
*/
private hasStateChanged(
files: Record<string, string>,
runnables: Record<string, Runnable>,
summary: string
): boolean {
if (this.entries.length === 0) return true
const lastEntry = this.entries[this.entries.length - 1]
return (
!deepEqual(lastEntry.files, files) ||
!deepEqual(lastEntry.runnables, runnables) ||
lastEntry.summary !== summary
)
}
/**
* Add a snapshot to the main timeline
*/
addSnapshot(entry: HistoryEntry): void {
if (this.isCreatingSnapshot) {
console.warn('Snapshot already in progress, skipping')
return
}
this.isCreatingSnapshot = true
try {
this.entries = [...this.entries, entry]
// FIFO: Remove oldest entries when exceeding limit
if (this.entries.length > this.config.maxEntries) {
const removed = this.entries.slice(0, this.entries.length - this.config.maxEntries)
this.entries = this.entries.slice(-this.config.maxEntries)
// Clean up branches that reference removed entries
const removedIds = new Set(removed.map((e) => e.id))
this.branches = this.branches.filter((b) => !removedIds.has(b.forkPointId))
}
this.hasPendingChanges = false
} finally {
this.isCreatingSnapshot = false
}
}
/**
* Mark that there are pending changes
* 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()
}
this.hasPendingChanges = true
}
/**
* Create a branch from the "future" entries when making changes from historical position
*/
private createBranchFromFuture(): void {
const forkEntry = this.entries[this.currentIndex]
const futureEntries = this.entries.slice(this.currentIndex + 1)
if (futureEntries.length > 0) {
const newBranch: HistoryBranch = {
id: this.branchIdCounter++,
forkPointId: forkEntry.id,
entries: futureEntries
}
this.branches = [...this.branches, newBranch]
}
// Truncate main timeline to current position
this.entries = this.entries.slice(0, this.currentIndex + 1)
this.currentIndex = -1
}
/**
* Promote current branch to main timeline
* The old main timeline (from fork point onwards) becomes a branch
*/
private promoteBranchToMain(): void {
const branch = this.branches.find((b) => b.id === this.currentBranchId)
if (!branch) return
// Find fork point in main timeline
const forkIndex = this.entries.findIndex((e) => e.id === branch.forkPointId)
if (forkIndex === -1) return
// Save the current main timeline's "future" as a new branch (if any entries after fork)
const mainFutureEntries = this.entries.slice(forkIndex + 1)
if (mainFutureEntries.length > 0) {
const oldMainBranch: HistoryBranch = {
id: this.branchIdCounter++,
forkPointId: branch.forkPointId,
entries: mainFutureEntries
}
this.branches = [...this.branches.filter((b) => b.id !== this.currentBranchId), oldMainBranch]
} else {
// Just remove the current branch from branches list
this.branches = this.branches.filter((b) => b.id !== this.currentBranchId)
}
// New main timeline: entries up to fork point + branch entries up to selected index
const branchEntriesUpToSelection = branch.entries.slice(0, this.currentBranchEntryIndex + 1)
this.entries = [...this.entries.slice(0, forkIndex + 1), ...branchEntriesUpToSelection]
// Reset selection state
this.currentBranchId = undefined
this.currentBranchEntryIndex = -1
this.currentIndex = -1
}
getId(): number {
return this.entryIdCounter
}
/**
* Manually create and add a snapshot
* @param force - If true, create snapshot even if state hasn't changed
*/
manualSnapshot(
files: Record<string, string>,
runnables: Record<string, Runnable>,
summary: string,
force = false
): HistoryEntry | undefined {
if (!force && !this.hasStateChanged(files, runnables, summary)) {
return
}
const entry = this.createSnapshot(files, runnables, summary)
this.addSnapshot(entry)
return entry
}
/**
* Start automatic snapshot timer
*/
startAutoSnapshot(
getState: () => {
files: Record<string, string>
runnables: Record<string, Runnable>
summary: string
}
): void {
this.stopAutoSnapshot()
this.getStateFn = getState
if (!this.config.autoSnapshotInterval) return
this.autoSnapshotTimer = setInterval(() => {
if (this.getStateFn && this.currentIndex === -1 && this.currentBranchId === undefined) {
const { files, runnables, summary } = this.getStateFn()
this.manualSnapshot(files, runnables, summary)
}
}, this.config.autoSnapshotInterval) as unknown as number
}
/**
* Stop automatic snapshot timer
*/
stopAutoSnapshot(): void {
if (this.autoSnapshotTimer !== undefined) {
clearInterval(this.autoSnapshotTimer)
this.autoSnapshotTimer = undefined
}
}
/**
* Select an entry (on main timeline or a branch)
* If there are pending changes, a snapshot should be created first by the caller
*/
selectEntry(id: number): HistoryEntry | undefined {
// Check main timeline first
const mainIndex = this.entries.findIndex((e) => e.id === id)
if (mainIndex !== -1) {
this.currentIndex = mainIndex
this.currentBranchId = undefined
this.currentBranchEntryIndex = -1
this.hasPendingChanges = false
return this.entries[mainIndex]
}
// Check branches
for (const branch of this.branches) {
const branchIndex = branch.entries.findIndex((e) => e.id === id)
if (branchIndex !== -1) {
this.currentBranchId = branch.id
this.currentBranchEntryIndex = branchIndex
this.currentIndex = -1
this.hasPendingChanges = false
return branch.entries[branchIndex]
}
}
return undefined
}
/**
* Clear selection (go back to latest state)
*/
clearSelection(): void {
this.currentIndex = -1
this.currentBranchId = undefined
this.currentBranchEntryIndex = -1
}
/**
* Get entry by ID (searches main timeline and branches)
*/
getEntryById(id: number): HistoryEntry | undefined {
const mainEntry = this.entries.find((e) => e.id === id)
if (mainEntry) return mainEntry
for (const branch of this.branches) {
const branchEntry = branch.entries.find((e) => e.id === id)
if (branchEntry) return branchEntry
}
return undefined
}
/**
* Get branch that contains an entry
*/
getBranchForEntry(id: number): HistoryBranch | undefined {
return this.branches.find((b) => b.entries.some((e) => e.id === id))
}
/**
* Undo to previous state
*/
undo(): HistoryEntry | null {
if (!this.canUndo) return null
if (this.currentIndex === -1) {
this.currentIndex = this.entries.length - 2
} else {
this.currentIndex--
}
this.hasPendingChanges = false
return this.entries[this.currentIndex]
}
/**
* Redo to next state
*/
redo(): HistoryEntry | null {
if (!this.canRedo) return null
this.currentIndex++
this.hasPendingChanges = false
return this.entries[this.currentIndex]
}
/**
* Clear all history
*/
clearHistory(): void {
this.entries = []
this.branches = []
this.currentIndex = -1
this.currentBranchId = undefined
this.currentBranchEntryIndex = -1
}
/**
* Cleanup resources
*/
destroy(): void {
this.stopAutoSnapshot()
this.clearHistory()
}
}

View File

@@ -45,7 +45,7 @@
}
</script>
<PanelSection size="lg" fullHeight={false} title="Backend" id="app-editor-runnable-panel">
<PanelSection size="lg" fullHeight={false} title="backend" id="app-editor-runnable-panel">
{#snippet action()}
<div class="flex flex-row gap-1">
<Button
@@ -129,7 +129,8 @@
{/if}
{#if warningCount > 0}
<div>
<div class="font-semibold text-yellow-600 dark:text-yellow-400 mb-1">Warnings</div>
<div class="font-semibold text-yellow-600 dark:text-yellow-400 mb-1">Warnings</div
>
{#each Object.entries(lintSnapshot.warnings) as [key, warnings]}
{#if warnings.length > 0}
<div class="mb-1">

View File

@@ -40,6 +40,7 @@
size="sm"
collapsible
initiallyCollapsed
titlePadding="pl-1 !text-tertiary"
fullHeight={false}
title="packages ({Object.keys(props.modules?.installed ?? {}).length})"
id="app-editor-frontend-panel-modules"

View File

@@ -4,9 +4,12 @@
import RawAppInlineScriptPanelList from './RawAppInlineScriptPanelList.svelte'
import FileTreeNode from './FileTreeNode.svelte'
import { buildFileTree } from './fileTreeUtils'
import { Plus, File, Folder, Undo2, Redo2 } from 'lucide-svelte'
import { Plus, File, Folder, Camera } from 'lucide-svelte'
import type { Modules } from './RawAppModules.svelte'
import RawAppModules from './RawAppModules.svelte'
import RawAppHistoryList from './RawAppHistoryList.svelte'
import type { RawAppHistoryManager } from './RawAppHistoryManager.svelte'
import Button from '../common/button/Button.svelte'
interface Props {
runnables: Record<string, Runnable>
@@ -15,6 +18,10 @@
modules?: Modules
onSelectFile?: (path: string) => void
selectedDocument: string | undefined
historyManager?: RawAppHistoryManager
historySelectedId?: number | undefined
onHistorySelect?: (id: number) => void
onManualSnapshot?: () => void
}
let {
@@ -23,7 +30,11 @@
files = $bindable(),
modules,
onSelectFile,
selectedDocument = $bindable()
selectedDocument = $bindable(),
historyManager,
historySelectedId,
onHistorySelect,
onManualSnapshot
}: Props = $props()
const fileTree = $derived(buildFileTree(Object.keys(files ?? {})))
@@ -31,53 +42,6 @@
let pathToRename = $state<string | undefined>(undefined)
let pathToExpand = $state<string | undefined>(undefined)
// History management for undo/redo
const MAX_HISTORY = 5
let history = $state<Record<string, string>[]>([])
let historyIndex = $state(-1)
const canUndo = $derived(historyIndex > 0)
const canRedo = $derived(historyIndex < history.length - 1)
function addToHistory(newFiles: Record<string, string>) {
// Remove any future history if we're not at the end
if (historyIndex < history.length - 1) {
history = history.slice(0, historyIndex + 1)
}
// Add new state
history = [...history, $state.snapshot(newFiles)]
// Keep only last MAX_HISTORY items
if (history.length > MAX_HISTORY) {
history = history.slice(-MAX_HISTORY)
} else {
historyIndex++
}
}
function undo() {
if (canUndo && files) {
historyIndex--
files = $state.snapshot(history[historyIndex])
}
}
function redo() {
if (canRedo && files) {
historyIndex++
files = $state.snapshot(history[historyIndex])
}
}
// Initialize history with current state
$effect(() => {
if (files && history.length === 0) {
history = [$state.snapshot(files)]
historyIndex = 0
}
})
function handleFileClick(path: string) {
console.log('File clicked:', path)
selectedDocument = path
@@ -93,7 +57,6 @@
const newPath = normalizedFolder + 'newfile.txt'
nfiles[newPath] = ''
files = nfiles
addToHistory(nfiles)
pathToRename = newPath
pathToExpand = normalizedFolder
}
@@ -148,7 +111,6 @@
}
files = nfiles
addToHistory(nfiles)
pathToRename = undefined
}
}
@@ -162,7 +124,6 @@
const newPath = normalizedFolder + 'newfolder/'
nfiles[newPath] = ''
files = nfiles
addToHistory(nfiles)
pathToRename = newPath
pathToExpand = normalizedFolder
}
@@ -199,7 +160,6 @@
nfiles[newPath] = ''
files = nfiles
addToHistory(nfiles)
pathToRename = newPath
if (targetFolder) {
pathToExpand = targetFolder
@@ -237,7 +197,6 @@
nfiles[newPath] = ''
files = nfiles
addToHistory(nfiles)
pathToRename = newPath
if (targetFolder) {
pathToExpand = targetFolder
@@ -267,7 +226,6 @@
files = nfiles
console.log(nfiles)
addToHistory(nfiles)
// Clear selection if deleted item was selected
if (selectedDocument === path || (isFolder && selectedDocument?.startsWith(path))) {
@@ -277,28 +235,9 @@
}
</script>
<!-- {JSON.stringify(history)} -->
<PanelSection size="lg" fullHeight={false} title="Frontend" id="app-editor-frontend-panel">
<PanelSection size="lg" fullHeight={false} title="frontend" id="app-editor-frontend-panel">
{#snippet action()}
<div class="flex gap-1">
<div class="flex gap-0.5 border-r border-gray-200 dark:border-gray-700 pr-1">
<button
onclick={undo}
disabled={!canUndo}
class="p-0.5 hover:bg-surface-hover rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="Undo (Ctrl+Z)"
>
<Undo2 size={12} class="text-secondary" />
</button>
<button
onclick={redo}
disabled={!canRedo}
class="p-0.5 hover:bg-surface-hover rounded transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title="Redo (Ctrl+Y)"
>
<Redo2 size={12} class="text-secondary" />
</button>
</div>
<div class="flex gap-0.5">
<button
onclick={handleAddRootFile}
@@ -350,5 +289,34 @@
<RawAppModules {modules} />
<div class="py-10"></div>
<div class="py-4"></div>
<RawAppInlineScriptPanelList bind:selectedRunnable {runnables} />
<div class="py-4"></div>
<PanelSection fullHeight={false} size="lg" title="data">
<span class="text-2xs text-tertiary">Coming soon</span>
</PanelSection>
{#if historyManager && onHistorySelect && onManualSnapshot}
<div class="py-4"></div>
<PanelSection fullHeight={false} size="md" title="history" id="app-editor-history-panel">
{#snippet action()}
<div class="flex items-center gap-2">
<span class="text-2xs text-tertiary">{historyManager.allEntries.length}/50</span>
<Button
size="xs2"
color="dark"
variant="border"
startIcon={{ icon: Camera }}
on:click={onManualSnapshot}
></Button>
</div>
{/snippet}
<RawAppHistoryList
entries={historyManager.allEntries}
branches={historyManager.allBranches}
selectedId={historySelectedId}
onSelect={onHistorySelect}
/>
</PanelSection>
{/if}