From 3d6a2bcef2e284d64690db04db32a2326a4b0670 Mon Sep 17 00:00:00 2001 From: Guilhem Date: Fri, 23 Oct 2026 17:58:44 +0200 Subject: [PATCH] fix: fork a raw app into the raw app builder, in its session when there (#11978) * fix: fork a raw app into the raw app builder, in its session when there Co-Authored-By: Claude Opus 5.5 (1M context) * fix: keep the source's sandbox and sdk scopes on a forked raw app Co-Authored-By: Claude Opus 5.5 (1M context) * fix: fork a raw app from the editor's content, not the server's copy Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- .../apps/editor/AppEditorHeader.svelte | 2 +- .../components/raw_apps/RawAppEditor.svelte | 4 ++ .../raw_apps/RawAppEditorHeader.svelte | 24 +++++++- .../components/raw_apps/forkRawApp.svelte.ts | 55 +++++++++++++++++++ .../sessions/RawAppEditorView.svelte | 12 +++- 5 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/components/raw_apps/forkRawApp.svelte.ts diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index e5613a71a1..f6f89b864f 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -1326,7 +1326,7 @@ { label: 'Fork', onClick: () => { - window.open(`/apps/add?template=${appPath}`) + window.open(`/apps/add?template=${encodeURIComponent($appPath)}`) } }, ...(!isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 67a3ccbf6a..55b211ecdd 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -173,6 +173,8 @@ // Threaded to the AutosaveIndicator's "Reset to deployed" button. /** `Exit & see details`: see RawAppEditorHeader. */ onDetails?: (e: { path: string }) => void + /** See RawAppEditorHeader. */ + onOpenFork?: (path: string) => void onResetToDeployed?: () => void | Promise /** The app_version the draft forked from, for the deploy-time "new version * deployed" guard: deploying is refused with a confirmation while it is not @@ -244,6 +246,7 @@ compact = false, pendingDraftPath = $bindable(undefined), onDetails, + onOpenFork, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -2726,6 +2729,7 @@ addEventListener('message', function (e) { {onNavigate} {onDeploy} {onDetails} + {onOpenFork} {onResetToDeployed} {loadedFromDraft} {othersDraftsCount} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index bc14c298e9..fbc391ac78 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -51,6 +51,7 @@ import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte' import type { Runnable } from './RawAppInlineScriptRunnable.svelte' import { updateRawAppPolicy } from './rawAppPolicy' + import { saveRawAppFork } from './forkRawApp.svelte' import { aiChatManager } from '../copilot/chat/AIChatManager.svelte' import { getContext } from 'svelte' @@ -188,6 +189,9 @@ /** `Exit & see details`: leaves the editor for the app's deployed page. A session hosts that * page itself, so it flips the tab there rather than navigating out of the session. */ onDetails?: (e: { path: string }) => void + /** Opens the draft `Fork` saved. Without it, the fork opens in a new browser tab; a + * session opens it as one of its own tabs instead. */ + onOpenFork?: (path: string) => void onResetToDeployed?: () => void | Promise // See ScriptBuilderProps — same semantics for the raw-app editor's // indicator. @@ -239,6 +243,7 @@ onDeploy = undefined, pendingDraftPath = $bindable(undefined), onDetails, + onOpenFork, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -257,6 +262,21 @@ const opWorkspace = $derived(autosaveWorkspace ?? $operatingWorkspace) const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath) + async function forkApp() { + if (!opWorkspace || !files) return + const workspace = opWorkspace + // Opened before the save: a tab opened after an await is no longer tied to the + // click, and browsers block it as a popup. + const tab = onOpenFork ? undefined : window.open('', '_blank') + const forkPath = await saveRawAppFork(workspace, { files, runnables, data, policy }) + if (!forkPath) { + tab?.close() + return + } + if (onOpenFork) onOpenFork(forkPath) + else if (tab) tab.location.href = `/apps_raw/edit/${forkPath}?workspace=${workspace}` + } + $effect(() => { const typed = newEditedPath const baseline = savedApp?.path ?? '' @@ -1217,9 +1237,7 @@ ]), { label: 'Fork', - onClick: () => { - window.open(`/apps/add?template=${appPath}`) - } + onClick: forkApp }, ...(!isCloudHosted() && editInForkAllowed(opWorkspace, $userWorkspaces) ? [ diff --git a/frontend/src/lib/components/raw_apps/forkRawApp.svelte.ts b/frontend/src/lib/components/raw_apps/forkRawApp.svelte.ts new file mode 100644 index 0000000000..ffeab765da --- /dev/null +++ b/frontend/src/lib/components/raw_apps/forkRawApp.svelte.ts @@ -0,0 +1,55 @@ +import { get } from 'svelte/store' +import type { Policy } from '$lib/gen' +import { userStore } from '$lib/stores' +import { sendUserToast } from '$lib/toast' +import { UserDraft } from '$lib/userDraft.svelte' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' +import { mintDraftPath } from '$lib/mintDraftPath' +import { userPathPrefix } from '$lib/utils' +import { random_adj } from '$lib/components/random_positive_adjetive' +import type { RawAppDraft } from '$lib/components/sessions/appDraftCodec' +import type { RawAppData } from './dataTableRefUtils' + +/** + * Saves what the editor holds as a new raw-app draft under a fresh path and returns that + * path, or `undefined` when the save failed. The copy is taken from the editor rather than + * reloaded from the server, which would miss edits autosave has not written (or never will, + * with auto-save off). + */ +export async function saveRawAppFork( + workspace: string, + source: { + files: Record + runnables: Record + data: RawAppData + policy: Policy | undefined + } +): Promise { + const user = get(userStore) + const forkPath = mintDraftPath() + const draft: RawAppDraft = { + summary: '', + files: $state.snapshot(source.files), + runnables: $state.snapshot(source.runnables), + data: $state.snapshot(source.data), + // The forker's identity, since the source's would publish as its owner, but the + // source's sandbox and SDK scopes: the copied code's SDK calls depend on them. + policy: { + on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`, + on_behalf_of_email: user?.email, + execution_mode: 'publisher', + sandbox: source.policy?.sandbox ?? false, + frontend_sdk_scopes: $state.snapshot(source.policy?.frontend_sdk_scopes) + }, + // Without it the fork would deploy under its `draft_` storage path. + draft_path: `${userPathPrefix(user?.username)}${random_adj()}_app` + } + UserDraft.save('raw_app', forkPath, draft, { workspace }) + await UserDraft.forcePersist('raw_app', forkPath, { workspace }) + const saved = UserDraftDbSyncer.getState({ workspace, itemKind: 'raw_app', path: forkPath }) + if (saved.state === 'failed') { + sendUserToast(`Could not save the fork: ${saved.failureMessage ?? 'unknown error'}`, true) + return undefined + } + return forkPath +} diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 81e219f7f4..50ea1e054a 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -100,7 +100,9 @@ files: { ...result.files }, runnables: { ...result.runnables, - ...(capabilitiesInWorkspace(workspaceId).apps === 'compose' ? {} : structuredClone(STARTER_RUNNABLES)) + ...(capabilitiesInWorkspace(workspaceId).apps === 'compose' + ? {} + : structuredClone(STARTER_RUNNABLES)) }, data: result.data, policy: result.policy @@ -162,6 +164,13 @@ }) }) + function openFork(forkPath: string) { + runtime.previewTabs.open({ + type: 'item', + item: { kind: 'app', raw_app: true, path: forkPath, summary: '' } + }) + } + async function reloadDeployed() { await runtime.loadRawApp(workspaceId, path, true, true) } @@ -380,6 +389,7 @@ {diffDrawer} {onNavigate} onDetails={onSeeDetails} + onOpenFork={openFork} condensedHeader={true} onResetToDeployed={reloadDeployed} onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', path, e.path)}