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) <noreply@anthropic.com>

* fix: keep the source's sandbox and sdk scopes on a forked raw app

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix: fork a raw app from the editor's content, not the server's copy

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem
2026-10-23 17:58:44 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 79fe8ab03f
commit 3d6a2bcef2
5 changed files with 92 additions and 5 deletions
@@ -1326,7 +1326,7 @@
{
label: 'Fork',
onClick: () => {
window.open(`/apps/add?template=${appPath}`)
window.open(`/apps/add?template=${encodeURIComponent($appPath)}`)
}
},
...(!isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces)
@@ -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<void>
/** 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) {
{getBundle}
{onNavigate}
{onDeploy}
{onDetails}
{onOpenFork}
{onResetToDeployed}
{loadedFromDraft}
@@ -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<void>
// 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)
? [
@@ -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<string, string>
runnables: Record<string, any>
data: RawAppData
policy: Policy | undefined
}
): Promise<string | undefined> {
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_<uuid>` 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
}
@@ -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)}