fix: check pinned versions and app kind before keeping an item page on switch

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guilhem Lemouel
2026-09-23 18:23:59 +02:00
co-authored by Claude Opus 5.5
parent 147dd09330
commit 0caf054295
5 changed files with 65 additions and 35 deletions
@@ -247,8 +247,7 @@
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
icon: Pen,
// No `href`: the handler resolves the destination asynchronously, and a melt
// menu item's anchor navigates before a delegated onclick can preventDefault it.
// No `href`: the handler resolves the destination asynchronously.
action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path),
hide:
$userStore?.operator ||
@@ -272,8 +272,7 @@
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
icon: Pen,
// No `href`: the handler resolves the destination asynchronously, and a melt
// menu item's anchor navigates before a delegated onclick can preventDefault it.
// No `href`: the handler resolves the destination asynchronously.
action: (e) => onEditInForkClick(e, 'flow', path),
hide:
$userStore?.operator ||
@@ -358,8 +358,7 @@
{
displayName: editInForkLabel($workspaceStore, $userWorkspaces),
icon: Pen,
// No `href`: the handler resolves the destination asynchronously, and a melt
// menu item's anchor navigates before a delegated onclick can preventDefault it.
// No `href`: the handler resolves the destination asynchronously.
action: (e) => onEditInForkClick(e, 'script', script.path),
hide:
$userStore?.operator ||
@@ -14,35 +14,61 @@ import { workspaceStore } from '$lib/stores'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { workspaceAIClients } from '$lib/components/copilot/lib'
type ItemExists = (workspace: string, param: string) => Promise<boolean>
// `search` is the page's query string: a `hash` or `version` in it pins one version of the
// item, which must exist in the target workspace, not just its path.
type ItemExists = (workspace: string, param: string, search: URLSearchParams) => Promise<boolean>
const appExists: ItemExists = (workspace, path) => AppService.existsApp({ workspace, path })
const flowExists: ItemExists = (workspace, path) =>
FlowService.existsFlowByPath({ workspace, path })
const scriptExists: ItemExists = (workspace, path) =>
ScriptService.existsScriptByPath({ workspace, path })
// A failed lookup (404 included) counts as missing.
async function succeeds(lookup: () => Promise<unknown>): Promise<boolean> {
try {
await lookup()
return true
} catch {
return false
}
}
const scriptHashExists = (workspace: string, hash: string) =>
succeeds(() => ScriptService.getScriptByHash({ workspace, hash }))
// The two app editors can't open each other's kind, so the target's app must match it.
const appExists =
(raw: boolean): ItemExists =>
async (workspace, path) => {
try {
return (await AppService.getAppLiteByPath({ workspace, path })).raw_app === raw
} catch {
return false
}
}
const flowExists: ItemExists = async (workspace, path, search) => {
const version = Number(search.get('version') ?? NaN)
if (!Number.isFinite(version)) return FlowService.existsFlowByPath({ workspace, path })
try {
return (await FlowService.getFlowVersion({ workspace, version })).path === path
} catch {
return false
}
}
const scriptExists: ItemExists = (workspace, path, search) => {
const hash = search.get('hash')
return hash
? scriptHashExists(workspace, hash)
: ScriptService.existsScriptByPath({ workspace, path })
}
// The script page's param is a hash, a path, or a hub path.
const scriptHashOrPathExists: ItemExists = async (workspace, hashOrPath) => {
if (hashOrPath.startsWith('hub/')) return true
if (await scriptExists(workspace, hashOrPath)) return true
try {
await ScriptService.getScriptByHash({ workspace, hash: hashOrPath })
return true
} catch {
return false
}
if (await ScriptService.existsScriptByPath({ workspace, path: hashOrPath })) return true
return scriptHashExists(workspace, hashOrPath)
}
// A one-row lookup, where getJob would download the job's args and result.
const jobExists: ItemExists = async (workspace, id) => {
try {
await JobService.getRootJobId({ workspace, id })
return true
} catch {
return false
}
}
const jobExists: ItemExists = (workspace, id) =>
succeeds(() => JobService.getRootJobId({ workspace, id }))
type ItemPage = {
param: string
@@ -62,14 +88,18 @@ const ITEM_PAGES: Record<string, ItemPage> = {
'/(root)/(logged)/scripts/get/[...hash]': { param: 'hash', exists: scriptHashOrPathExists },
'/(root)/(logged)/flows/edit/[...path]': { param: 'path', exists: flowExists, draftKind: 'flow' },
'/(root)/(logged)/flows/get/[...path]': { param: 'path', exists: flowExists },
'/(root)/(logged)/apps/edit/[...path]': { param: 'path', exists: appExists, draftKind: 'app' },
'/(root)/(logged)/apps/get/[...path]': { param: 'path', exists: appExists },
'/(root)/(logged)/apps/edit/[...path]': {
param: 'path',
exists: appExists(false),
draftKind: 'app'
},
'/(root)/(logged)/apps/get/[...path]': { param: 'path', exists: appExists(false) },
'/(root)/(logged)/apps_raw/edit/[...path]': {
param: 'path',
exists: appExists,
exists: appExists(true),
draftKind: 'raw_app'
},
'/(root)/(logged)/apps_raw/get/[...path]': { param: 'path', exists: appExists },
'/(root)/(logged)/apps_raw/get/[...path]': { param: 'path', exists: appExists(true) },
'/(root)/(logged)/run/[...run]': { param: 'run', exists: jobExists }
}
@@ -83,7 +113,7 @@ async function itemPageMissingIn(workspace: string): Promise<boolean> {
const current = currentItemPage()
if (!current) return false
try {
return !(await current.itemPage.exists(workspace, current.param))
return !(await current.itemPage.exists(workspace, current.param, page.url.searchParams))
} catch {
return true
}
@@ -121,8 +151,10 @@ export async function switchWorkspaceAndPage(
href?: string
}
): Promise<void> {
const missing = !opts?.landOnHome && (await itemPageMissingIn(id))
// Read after the lookup: the user can edit while it is in flight.
const unsavedEdits = editorHasUnsavedEdits()
if (opts?.landOnHome || unsavedEdits || (await itemPageMissingIn(id))) {
if (opts?.landOnHome || unsavedEdits || missing) {
// Leave before switching: an item page still mounted when the store changes
// refetches its item in `id` and toasts the 404.
// The param carries the switch through the editor's unsaved-changes prompt: that
@@ -13,6 +13,7 @@
} from '$lib/gen'
import { capitalize, classNames, getModifierKey, sendUserToast } from '$lib/utils'
import { useLocalStorageValue } from '$lib/svelte5Utils.svelte'
import { switchWorkspace } from '$lib/storeUtils'
import { isSessionPreviewFrame } from '$lib/components/sessions/sessionMode.svelte'
import WorkspaceMenu from '$lib/components/sidebar/WorkspaceMenu.svelte'
import SidebarContent from '$lib/components/sidebar/SidebarContent.svelte'
@@ -299,8 +300,8 @@
function onQueryChange() {
let queryWorkspace = page.url.searchParams.get('workspace')
if (queryWorkspace) {
$workspaceStore = queryWorkspace
if (queryWorkspace && queryWorkspace !== $workspaceStore) {
switchWorkspace(queryWorkspace)
}
// When this window is an iframe (e.g. the sessions preview), keep the menu