Files
windmill/frontend/src/lib/components/apps/editor/InWorkspaceAppViewer.svelte
T
Guilhem LemouelandClaude Opus 5 7e28a10eb2 feat: the band narrows, wraps, and draws its own edge
The band publishes its width, so the pages filling it can answer it. Two
tiers: under 1000px the breadcrumb keeps the workspace disc and its env
badge and drops the name, and under PHONE_BAR (800) the trail stops
yielding at 10rem — that floor is what makes the bar wrap, so a page's
buttons take the line below rather than squeezing the name to an ellipsis.

The band also draws the line between itself and the page, and only while
something is scrolled under it. One capture listener on the window sees
every scroller, since most pages scroll inside their own box and never
move the layout's. An inset shadow, not a border: a border would move
every page down a pixel the moment the line appeared.

DetailPageLayout and InWorkspaceAppViewer drew that first line themselves;
two lines would stack, so theirs go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 02:45:53 -07:00

169 lines
6.0 KiB
Svelte

<script lang="ts">
/*
* WIN-2006: shared in-workspace app viewer (low-code AND raw). Renders the app
* through the same PublicAppFrame -> PublicApp machinery the public viewer uses,
* so the sandbox behavior is identical on every page. PublicAppFrame picks the
* rendering: an opaque /app_embed iframe for sandboxed low-code, or inline (with
* the bundle isolated in RawAppPreview's own opaque iframe) for raw and for
* unsandboxed apps. When the publisher opts an app into sandbox isolation, its
* untrusted markup/JS must not run with the member's full session — hence the
* scoped embed token / opaque isolation.
*/
import { base } from '$lib/base'
import PublicApp from '$lib/components/apps/editor/PublicApp.svelte'
import PublicAppFrame from '$lib/components/apps/editor/PublicAppFrame.svelte'
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
import { Button } from '$lib/components/common'
import { AppService, OpenAPI } from '$lib/gen'
import { userStore } from '$lib/stores'
import { canWrite } from '$lib/utils'
import { getUserExt } from '$lib/user'
import { Pen } from 'lucide-svelte'
import { page } from '$app/state'
let {
workspace,
path
}: {
workspace: string
path: string
} = $props()
let app: any = $state(undefined)
let notExists = $state(false)
let noPermission = $state(false)
let canWriteApp = $state(false)
/** Raw vs low-code, read from the app itself rather than from the route:
* both kinds render here and either route serves either kind (links to a raw
* app point at /apps/get all over the app), so only the app can say which
* editor the Edit button must open. */
let isRawApp = $state(false)
let editHref = $derived(`${base}/${isRawApp ? 'apps_raw' : 'apps'}/edit/${path}?nodraft=true`)
let refresh: (() => void) | undefined
// The opaque iframe loads the dedicated cookieless, chrome-less viewer route.
// The page's query/hash are forwarded so the app sees the same `ctx.query` /
// `ctx.hash` as the pre-sandbox viewer did. Captured ONCE (not reactively):
// the embedder later mirrors the app's own hash/query changes back onto this
// page's URL (wm_embed_hash relay), and re-deriving the iframe src from them
// would reload the app on its every navigation.
const initialSearchHash = page.url.search + page.url.hash
let viewerUrl = $derived(`${base}/app_embed/${workspace}/${path}${initialSearchHash}`)
const hideEditBtn = page.url.searchParams.get('hideEditBtn') === 'true'
const hideRefreshBar = page.url.searchParams.get('hideRefreshBar') === 'true'
// Embedder side: mint a scoped embed token (by path) from the member's session.
async function fetchEmbedToken(opts?: { sdkConsent?: boolean }): Promise<{ token?: string }> {
const headers: Record<string, string> = {}
if (typeof OpenAPI.TOKEN === 'string' && OpenAPI.TOKEN) {
headers['Authorization'] = `Bearer ${OpenAPI.TOKEN}`
}
const consent = opts?.sdkConsent ? '?sdk_consent=true' : ''
const res = await fetch(`${OpenAPI.BASE}/w/${workspace}/apps/embed_token/p/${path}${consent}`, {
headers
})
if (!res.ok) {
const err: any = new Error('Failed to fetch embed token')
err.status = res.status
throw err
}
return await res.json()
}
// Viewer side — used for the inline renderings (raw, and unsandboxed low-code);
// the sandboxed low-code case loads inside the opaque /app_embed iframe instead.
// getAppByPath returns bundle_secret + runnables for raw apps, which
// PublicApp -> RawAppPreview needs.
async function loadApp() {
try {
userStore.set(await getUserExt(workspace))
} catch (e) {
console.warn('Anonymous user')
}
try {
const loaded: any = await AppService.getAppByPath({ workspace, path })
// Raw apps need the bundle secret to load their bundle. getAppByPath
// doesn't compute it (unlike the public handlers), so fetch it here — the
// same call the previous raw viewer used — and hand it to PublicApp ->
// RawAppPreview via bundle_secret.
if (loaded?.raw_app && !loaded.bundle_secret) {
try {
loaded.bundle_secret = await AppService.getPublicSecretOfLatestVersionOfApp({
workspace,
path
})
} catch (e) {
console.error('Failed to load raw app bundle secret', e)
}
}
app = loaded
noPermission = false
notExists = false
} catch (e: any) {
if (e.status == 401) refresh?.()
else if (e.status == 403) noPermission = true
else notExists = true
}
}
// Edit button: determine write access and which editor to open on this
// real-origin page (cookie). The sandboxed low-code app never loads on this
// page (it loads inside the opaque iframe), so `app` can't be the source.
async function loadPerms() {
try {
const lite: any = await AppService.getAppLiteByPath({ workspace, path })
canWriteApp = canWrite(lite?.path, lite?.extra_perms ?? {}, $userStore)
isRawApp = !!lite?.raw_app
} catch (_) {
canWriteApp = false
}
}
$effect(() => {
if (workspace && path) loadPerms()
})
</script>
<div class="h-full">
<PublicAppFrame
{fetchEmbedToken}
{viewerUrl}
onViewerReady={(_token, requestTokenRefresh) => {
refresh = requestTokenRefresh
loadApp()
}}
>
{#snippet viewer()}
<PublicApp
{app}
{workspace}
{notExists}
{noPermission}
jwtError={false}
inWorkspace
{hideRefreshBar}
onLoginSuccess={() => loadApp()}
></PublicApp>
{/snippet}
</PublicAppFrame>
</div>
{#if canWriteApp && !hideEditBtn}
<!-- The page header carries it, with the app's name: a floating button over the app's own
canvas lands on whatever the app draws there. -->
<!-- Edit sits with the app's name rather than at the far end of the bar: on a page whose header
is only there while hovered, the far end is a journey across the window. -->
<PageHeaderContent item={{ kind: 'app', path }} afterName={editAction} fullBleed />
{/if}
{#snippet editAction()}
<Button
unifiedSize="sm"
startIcon={{ icon: Pen }}
variant="subtle"
href={editHref}
id="app-edit-btn">Edit</Button
>
{/snippet}