fix: add history to raw app builder (#7362)
* appHistory * appHistory * all * all * all * all * all * all * all * all * improvements
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/')
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
13
frontend/src/lib/components/copilot/chat/AIButtonStyle.ts
Normal file
13
frontend/src/lib/components/copilot/chat/AIButtonStyle.ts
Normal 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'
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
123
frontend/src/lib/components/raw_apps/RawAppHistoryList.svelte
Normal file
123
frontend/src/lib/components/raw_apps/RawAppHistoryList.svelte
Normal 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}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user