mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-18 08:03:11 +00:00
The page header decides whether a separator is drawn, so a page cannot draw a second line under it. A registration asks for `onScroll` (the default, drawn only while something passes under the bar), `always` for a page whose content starts right beneath it, or `none` where the page genuinely draws its own first line. Detail pages, the agent editor, workspace settings and the pipeline list take `always`; the raw app editor takes `none`, since its splitpanes already draw that line. The pen that opens the rename popover becomes PathEditPen, shared by the editors and the deployed pages so the two cannot drift, and the deployed pages gain it. SummaryPathDisplay makes the pen the only trigger rather than the whole name block, so the name is selectable text again. Renaming an agent goes through ResourceService with only the fields the popover edits: the backend sets a column per field it is given, so writing the config back would clobber a concurrent edit. The popover also stops clearing a real collision verdict by telling Path which path is its own. Two Button fixes, both reaching call sites beyond this work: - `dropdownItems` given as a function was asked for `.length`, which is its arity and therefore 0, so a split button built that way rendered no caret. Both app editors' Deploy dropdowns were unreachable. - the anchor branch dropped `title`, so a link button had no tooltip. The audit log filters move into the header actions at the design system's sizes and trade absolutely positioned labels for the form layout in brand-guidelines.md: a label above its input, 4px apart, fields 8px apart. The deployed app viewer floors itself at `min-h-full` instead of claiming the viewport, which would overhang the page box by the height of the band, and carries Edit and a public-url link that fold into a menu on a narrow bar. The root layout drops the legacy menu block, which rendered a second hidden sidebar and 54 invisible tab stops, and marks the closed detached sidebar `inert`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1181 lines
38 KiB
Svelte
1181 lines
38 KiB
Svelte
<script lang="ts">
|
|
import { Drawer, DrawerContent } from '$lib/components/common'
|
|
import { base } from '$lib/base'
|
|
import RawAppRecordSession from '$lib/components/workspaceSettings/RawAppRecordSession.svelte'
|
|
import Button from '$lib/components/common/button/Button.svelte'
|
|
import { isMac, userPathPrefix, emptyString } from '$lib/utils'
|
|
import { editPathFor } from '$lib/components/workspacePicker'
|
|
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
|
|
|
import { AppService, type Policy } from '$lib/gen'
|
|
import { UserDraft } from '$lib/userDraft.svelte'
|
|
import OpenInSessionButton, {
|
|
type OpenInSessionSource
|
|
} from '$lib/components/sessions/OpenInSessionButton.svelte'
|
|
import { discardDraftAfterDeploy } from '$lib/userDraftToast'
|
|
import { enterpriseLicense, userStore, userWorkspaces } from '$lib/stores'
|
|
import {
|
|
Bug,
|
|
DiffIcon,
|
|
EllipsisVertical,
|
|
FileJson,
|
|
Circle,
|
|
History,
|
|
PanelLeft,
|
|
PanelLeftClose,
|
|
Redo,
|
|
Save,
|
|
Undo,
|
|
WandSparkles
|
|
} from 'lucide-svelte'
|
|
import { onDestroy, untrack } from 'svelte'
|
|
import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../utils'
|
|
import { random_adj } from '$lib/components/random_positive_adjetive'
|
|
|
|
// import { allItems, toStatic } from '../apps/editor/settingsPanel/utils'
|
|
import AppExportButton from '../apps/editor/AppExportButton.svelte'
|
|
|
|
import { sendUserToast } from '$lib/toast'
|
|
import DeploymentHistory from '../apps/editor/DeploymentHistory.svelte'
|
|
import Awareness from '$lib/components/Awareness.svelte'
|
|
import type DiffDrawer from '$lib/components/DiffDrawer.svelte'
|
|
import { VERSION_PAGE_SIZE } from '$lib/components/diff_drawer'
|
|
|
|
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
|
import AutosaveIndicator from '$lib/components/AutosaveIndicator.svelte'
|
|
import { goto } from '$app/navigation'
|
|
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
|
|
|
import AppJobsDrawer from '../apps/editor/AppJobsDrawer.svelte'
|
|
import DropdownV2 from '../DropdownV2.svelte'
|
|
import { stateSnapshot } from '$lib/svelte5Utils.svelte'
|
|
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 { getContext } from 'svelte'
|
|
|
|
// Forward-looking hook for the upcoming session-pane feature: that PR will
|
|
// `setContext('aiChatManager', ...)` from the session wrapper so this editor
|
|
// can detect it and hide its own AI button. On main today nothing sets the
|
|
// context, so `inSessionPane` is always false and the AI button renders
|
|
// normally — keep the check to avoid re-touching this file when the
|
|
// session-pane PR lands. Untyped getContext to avoid coupling to the
|
|
// AIChatManager class export (which lives on the chat-visuals PR).
|
|
const inSessionPane = !!getContext('aiChatManager')
|
|
// In a session pane the editor does NOT own the localStorage draft — the
|
|
// session runtime does, keyed by the session's (fork) workspace. So the
|
|
// `if (!inSessionPane) UserDraft.remove(...)` guards below skip the editor's
|
|
// own removal (it would target the wrong, navigation-workspace key). The
|
|
// session-side equivalent is RawAppEditorView's `onDeploy` →
|
|
// `runtime.itemDeployed`, which discards the fork draft + reloads
|
|
// the preview to the deployed version.
|
|
import { AIBtnClasses } from '../copilot/chat/AIButtonStyle'
|
|
import { stripRawAppDiffNoise } from './utils'
|
|
import type { RawAppData } from './dataTableRefUtils'
|
|
import { editInForkAllowed, editInForkLabel, openEditInFork } from '$lib/utils/editInFork'
|
|
import { isCloudHosted } from '$lib/cloud'
|
|
import {
|
|
operatingWorkspaceContextEntry,
|
|
useOperatingUser,
|
|
useOperatingWorkspace
|
|
} from '$lib/components/operatingWorkspace.svelte'
|
|
import PageHeaderContent from '$lib/components/PageHeaderContent.svelte'
|
|
import { pageHeader, PHONE_BAR } from '$lib/components/pageHeaderRegistry.svelte'
|
|
import PathEditPopover from '$lib/components/PathEditPopover.svelte'
|
|
|
|
const operatingWorkspace = useOperatingWorkspace()
|
|
const operatingUser = useOperatingUser()
|
|
const actingUser = $derived(operatingUser.current)
|
|
|
|
// async function hash(message) {
|
|
// try {
|
|
// const msgUint8 = new TextEncoder().encode(message) // encode as (utf-8) Uint8Array
|
|
// const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8) // hash the message
|
|
// const hashArray = Array.from(new Uint8Array(hashBuffer)) // convert buffer to byte array
|
|
// const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string
|
|
// return hashHex
|
|
// } catch {
|
|
// //subtle not available, trying pure js
|
|
// const hash = new Sha256()
|
|
// hash.update(message ?? '')
|
|
// const result = Array.from(await hash.digest())
|
|
// const hex = result.map((b) => b.toString(16).padStart(2, '0')).join('') // convert bytes to hex string
|
|
// return hex
|
|
// }
|
|
|
|
interface Props {
|
|
// }
|
|
summary: string
|
|
policy: Policy
|
|
diffDrawer?: DiffDrawer | undefined
|
|
savedApp?:
|
|
| {
|
|
value: any
|
|
path: string
|
|
summary: string
|
|
policy: any
|
|
custom_path?: string
|
|
labels?: string[]
|
|
/** No deployed counterpart exists (draft-only); disables Diff. */
|
|
no_deployed?: boolean
|
|
}
|
|
| undefined
|
|
version?: number | undefined
|
|
/** Moves the draft's base to the deployed head and keeps its content;
|
|
* offered in the diff drawer while the draft is behind. */
|
|
onTakeLatest?: (head?: string) => void | Promise<void>
|
|
/** The app_version the draft forked from; the drawer offers `onTakeLatest` only
|
|
* while it differs from the head on display. */
|
|
draftBaseVersion?: string | undefined
|
|
newApp: boolean
|
|
newPath?: string
|
|
/** Initial labels for the app, threaded from the loaded app data. */
|
|
labels?: string[]
|
|
appPath: string
|
|
/** "Open in AI session" hand-off, owned by the editor (it persists the
|
|
* draft the session preview loads). Undefined until the app has a path. */
|
|
sessionOpen?: OpenInSessionSource
|
|
runnables: Record<string, Runnable>
|
|
files: Record<string, string> | undefined
|
|
/** Data configuration including tables and creation policy */
|
|
data: RawAppData
|
|
jobs: string[]
|
|
jobsById: Record<string, any>
|
|
getBundle: () => Promise<{
|
|
js: string
|
|
css: string
|
|
}>
|
|
canUndo?: boolean
|
|
canRedo?: boolean
|
|
onUndo?: () => void
|
|
onRedo?: () => void
|
|
onOpenYamlEditor?: () => void
|
|
sidebarCollapsed?: boolean
|
|
onToggleSidebar?: () => void
|
|
/** Condensed top bar: smaller (sm) buttons, a shorter bar, and the
|
|
* EditorHeader's path/breadcrumb row dropped (summary only). Used by the
|
|
* session preview to save vertical room. */
|
|
condensedHeader?: boolean
|
|
/** True for the route's own editor: its top bar becomes the page header. */
|
|
ownsPageHeader?: boolean
|
|
onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void
|
|
liveEditorDraftStoragePath?: string
|
|
/** Indicator-only overrides for the sessions preview: the AutosaveIndicator
|
|
* watches the session's (workspace, path) so it renders + animates on the
|
|
* key SessionEditorTarget saves under. Undefined on the full-page editor →
|
|
* falls back to the operating workspace/`liveEditorDraftStoragePath`. */
|
|
autosaveWorkspace?: string
|
|
autosavePath?: string
|
|
// Fired after a successful deploy; lets the session preview reload. `version` is
|
|
// what this deploy wrote, for the next draft's fork base; `head` is what is
|
|
// deployed now, with its author and time, and the two differ when another deploy
|
|
// landed beside this one.
|
|
onDeploy?: (e: {
|
|
path: string
|
|
version?: number
|
|
head?: number
|
|
headBy?: string
|
|
headAt?: string
|
|
}) => void
|
|
/** Surfaces the user-typed path (`newEditedPath`) up to the route
|
|
* when (and only when) it differs from the deployed/seeded
|
|
* `savedApp.path`. The route writes it into the autosaved raw-app
|
|
* draft as `draft_path` so the home-page row can render the
|
|
* friendly name instead of the URL's autogenerated draft slot. */
|
|
pendingDraftPath?: string | undefined
|
|
// Threaded to the `AutosaveIndicator` popover so its "Reset to
|
|
// deployed" button can do the same thing the load-time toast offers.
|
|
/** `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
|
|
onResetToDeployed?: () => void | Promise<void>
|
|
// See ScriptBuilderProps — same semantics for the raw-app editor's
|
|
// indicator.
|
|
loadedFromDraft?: boolean
|
|
othersDraftsCount?: number
|
|
onOpenOthersDrafts?: () => void
|
|
// Restoring an older deployment from the history drawer. A callback prop
|
|
// (not `on:restore` forwarding): forwarding a `createEventDispatcher`
|
|
// event up through these runes-mode components silently drops it.
|
|
onRestore?: (restoredApp: any) => void
|
|
// Deploy created the app at a new path; the page navigates to it. Callback
|
|
// prop for the same reason as `onRestore` — `on:savedNewAppPath` forwarding
|
|
// through these runes-mode components is dropped.
|
|
onSavedNewAppPath?: (path: string) => void
|
|
}
|
|
|
|
let {
|
|
summary = $bindable(),
|
|
policy = $bindable(),
|
|
diffDrawer = undefined,
|
|
savedApp = $bindable(undefined),
|
|
version = $bindable(undefined),
|
|
onTakeLatest = undefined,
|
|
draftBaseVersion = undefined,
|
|
newApp,
|
|
newPath = '',
|
|
labels: initialLabels = undefined,
|
|
appPath,
|
|
sessionOpen,
|
|
runnables,
|
|
data,
|
|
files,
|
|
jobs = $bindable(),
|
|
jobsById = $bindable(),
|
|
getBundle,
|
|
canUndo = false,
|
|
canRedo = false,
|
|
onUndo = undefined,
|
|
onRedo = undefined,
|
|
onOpenYamlEditor = undefined,
|
|
sidebarCollapsed = false,
|
|
onToggleSidebar = undefined,
|
|
condensedHeader = false,
|
|
ownsPageHeader = false,
|
|
onNavigate = undefined,
|
|
liveEditorDraftStoragePath = undefined,
|
|
autosaveWorkspace = undefined,
|
|
autosavePath = undefined,
|
|
onDeploy = undefined,
|
|
pendingDraftPath = $bindable(undefined),
|
|
onDetails,
|
|
onResetToDeployed,
|
|
loadedFromDraft = false,
|
|
othersDraftsCount = 0,
|
|
onOpenOthersDrafts,
|
|
onRestore,
|
|
onSavedNewAppPath
|
|
}: Props = $props()
|
|
|
|
// Set by the on-behalf-of selector when the publisher picks a user other than
|
|
// themselves. Forwarded as `preserve_on_behalf_of` so the backend keeps the
|
|
// policy's on_behalf_of instead of resetting it to the deploying user.
|
|
let preserveOnBehalfOf = $state(false)
|
|
|
|
// The AutosaveIndicator watches these; in the sessions preview they're the
|
|
// session's (workspace, path), else the full-page editor's own values.
|
|
const opWorkspace = $derived(autosaveWorkspace ?? $operatingWorkspace)
|
|
const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath)
|
|
|
|
$effect(() => {
|
|
const typed = newEditedPath
|
|
const baseline = savedApp?.path ?? ''
|
|
untrack(() => {
|
|
pendingDraftPath = typed && typed !== baseline ? typed : undefined
|
|
})
|
|
})
|
|
|
|
// `newApp` is true both for a brand-new app AND (in the session preview) for a
|
|
// draft-only one that already has a real path — so prefer the real `appPath`,
|
|
// but NOT a `draft_{uuid}` storage placeholder (a brand-new app is parked at
|
|
// `u/{user}/draft_{uuid}`). A real named path is kept (else its breadcrumb shows
|
|
// a random name and deploy createApps under it); a placeholder still falls
|
|
// through to the friendly generated suggestion.
|
|
let newEditedPath = $state(
|
|
untrack(() => {
|
|
const realAppPath = appPath && !appPath.split('/').pop()?.startsWith('draft_') ? appPath : ''
|
|
return newApp
|
|
? newPath || realAppPath || userPathPrefix($userStore?.username) + random_adj() + '_app'
|
|
: newPath || appPath || ''
|
|
})
|
|
)
|
|
|
|
$effect(() => {
|
|
if (liveEditorDraftStoragePath === undefined || !opWorkspace) return
|
|
const workspace = opWorkspace
|
|
UserDraft.setLiveEditorDraft({
|
|
workspace,
|
|
itemKind: 'raw_app',
|
|
storagePath: liveEditorDraftStoragePath,
|
|
effectivePath: newEditedPath || appPath || savedApp?.path
|
|
})
|
|
return () =>
|
|
UserDraft.clearLiveEditorDraft('raw_app', {
|
|
workspace,
|
|
storagePath: liveEditorDraftStoragePath
|
|
})
|
|
})
|
|
|
|
let deployedValue: Value | undefined = $state(undefined) // Value to diff against
|
|
let deployedBy: string | undefined = $state(undefined) // Author
|
|
let confirmCallback: () => void = $state(() => {}) // What happens when user clicks `override` in warning
|
|
let open: boolean = $state(false) // Is confirmation modal open
|
|
|
|
// const { app, summary, appPath, jobs, jobsById, staticExporter } = getContext('AppViewerContext')
|
|
|
|
const loading = $state({
|
|
publish: false,
|
|
save: false
|
|
})
|
|
|
|
let pathError: string = $state('')
|
|
let appExport = $state() as AppExportButton | undefined
|
|
|
|
let saveDrawerOpen = $state(false)
|
|
let historyBrowserDrawerOpen = $state(false)
|
|
let recordDrawer = $state<Drawer | undefined>(undefined)
|
|
let deploymentMsg: string | undefined = $state(undefined)
|
|
|
|
// Top-bar responsive collapse — container width, not viewport.
|
|
let topbarWidth = $state(0)
|
|
// In the page header the buttons share the row with the breadcrumb, so they collapse sooner:
|
|
// the trail and the summary take ~430px of the same row, and the whole group — the menu, Diff,
|
|
// Jobs, Export, AI and Deploy — is ~560px.
|
|
const compactBelow = $derived(ownsPageHeader ? 1350 : 720)
|
|
const compactTopbar = $derived.by(() => {
|
|
const w = ownsPageHeader ? pageHeader.barWidth : topbarWidth
|
|
return w > 0 && w < compactBelow
|
|
})
|
|
// A phone's bar keeps the trail, the file sidebar's toggle, Diff and Deploy. AI stands down —
|
|
// the sidebar's own Ask AI is the way in at that width — while Jobs and the export are already
|
|
// in the menu, Jobs from `compactTopbar` (true at any phone width) and the export always.
|
|
const phoneTopbar = $derived(
|
|
ownsPageHeader && pageHeader.barWidth > 0 && pageHeader.barWidth < PHONE_BAR
|
|
)
|
|
|
|
// Top-bar button size + bar height. Condensed (session preview) uses the
|
|
// smallest well-supported unified size (`sm`) so the bar is thinner.
|
|
// In the page header the buttons ride a 44px band, the same size the condensed session-pane
|
|
// header uses.
|
|
const headerBtnSize = $derived(condensedHeader || ownsPageHeader ? 'sm' : 'md')
|
|
|
|
/** Held by the pen's popover while it is open; the band's trail reads it so the path does not
|
|
* reflow under the pointer as the user types. */
|
|
let pathSnapshot = $state<string | undefined>(undefined)
|
|
|
|
// The actions render under the band, outside the subtree RawAppEditor declared the operating
|
|
// workspace in, so it has to travel with them: OpenInSessionButton asks who the acting user is.
|
|
const headerContexts = new Map<any, any>([operatingWorkspaceContextEntry()])
|
|
|
|
function closeSaveDrawer() {
|
|
saveDrawerOpen = false
|
|
}
|
|
|
|
async function computeTriggerables() {
|
|
policy = await updateRawAppPolicy(runnables, policy)
|
|
}
|
|
|
|
async function createApp(path: string) {
|
|
if (!app) {
|
|
sendUserToast(`App hasn't been loaded yet`, true)
|
|
return
|
|
}
|
|
if (!policy.execution_mode) {
|
|
policy.execution_mode = 'publisher'
|
|
}
|
|
await computeTriggerables()
|
|
try {
|
|
const { js, css } = await getBundle()
|
|
await AppService.createAppRaw({
|
|
workspace: opWorkspace!,
|
|
formData: {
|
|
app: {
|
|
value: app,
|
|
path,
|
|
summary: summary,
|
|
policy,
|
|
deployment_message: deploymentMsg,
|
|
custom_path: customPath,
|
|
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
|
|
labels
|
|
},
|
|
js,
|
|
css
|
|
}
|
|
})
|
|
// New path now exists server-side — drop the autocomplete cache so
|
|
// it shows up immediately instead of after the 60s TTL.
|
|
invalidateWorkspacePaths(opWorkspace!)
|
|
savedApp = {
|
|
summary: summary,
|
|
value: structuredClone(stateSnapshot(app)),
|
|
path: path,
|
|
policy: policy,
|
|
custom_path: customPath,
|
|
labels: $state.snapshot(labels)
|
|
}
|
|
closeSaveDrawer()
|
|
sendUserToast('App deployed successfully')
|
|
// Canonical autosave key (the URL slot `appPath`), NOT the
|
|
// just-typed deploy `path` — for a draft-only app they differ
|
|
// (`u/{user}/draft_{uuid}` vs the chosen path), so removing at
|
|
// `path` orphaned the real draft row. Bracketed + flushed:
|
|
// RawAppEditor stays mounted through the post-deploy navigation
|
|
// and its mirror would otherwise displace the queued delete.
|
|
if (!inSessionPane && $operatingWorkspace) {
|
|
discardDraftAfterDeploy({
|
|
workspace: $operatingWorkspace,
|
|
itemKind: 'raw_app',
|
|
path: appPath
|
|
})
|
|
}
|
|
onSavedNewAppPath?.(path)
|
|
onDeploy?.({ path })
|
|
} catch (e) {
|
|
sendUserToast(`Error creating app: ${e.body ?? e.message}`, true)
|
|
}
|
|
}
|
|
|
|
async function handleUpdateApp(npath: string) {
|
|
// We have to make sure there is no updates when we clicked the button
|
|
await compareVersions()
|
|
|
|
if (onLatest) {
|
|
// Handle directly
|
|
await updateApp(npath)
|
|
} else {
|
|
// There is onLatest, but we need more information while deploying
|
|
// We need it to show diff
|
|
// Handle through confirmation modal
|
|
await syncWithDeployed()
|
|
if (
|
|
deployedValue &&
|
|
savedApp &&
|
|
app &&
|
|
orderedJsonStringify(deployedValue) ===
|
|
orderedJsonStringify(replaceFalseWithUndefined(currentDiffValue))
|
|
) {
|
|
await updateApp(npath)
|
|
} else {
|
|
confirmCallback = async () => {
|
|
open = false
|
|
await updateApp(npath)
|
|
}
|
|
// Open confirmation modal
|
|
open = true
|
|
}
|
|
}
|
|
}
|
|
|
|
async function syncWithDeployed(opening?: number) {
|
|
const deployedApp = await AppService.getAppByPath({
|
|
workspace: opWorkspace!,
|
|
path: appPath!,
|
|
withStarredInfo: true
|
|
})
|
|
|
|
// A superseded opening must not write these: the current one would then render
|
|
// and offer Take latest against the older head.
|
|
if (opening != null && !diffDrawer?.ownsOpening(opening)) return
|
|
deployedBy = deployedApp.created_by
|
|
const shownVersions = (deployedApp as { versions?: number[] }).versions
|
|
deployedVersionShown = Array.isArray(shownVersions)
|
|
? shownVersions[shownVersions.length - 1]
|
|
: undefined
|
|
|
|
// Normalize away post-deploy noise (see stripRawAppDiffNoise) so the
|
|
// diff/comparison only reflects what the editor actually changed.
|
|
deployedValue = replaceFalseWithUndefined(stripRawAppDiffNoise(deployedApp))
|
|
}
|
|
|
|
/** The app_version the payload in `deployedValue` came from, so the picker marks that
|
|
* one as head rather than trusting the history's first row. */
|
|
let deployedVersionShown: number | undefined = $state(undefined)
|
|
|
|
/** Deployed versions for the diff picker, newest first. Best-effort: losing the
|
|
* list costs the picker, not the diff. */
|
|
/** Throws: the drawer says so and lets the reader ask for the same page again. */
|
|
async function fetchVersionPage(page: number) {
|
|
if (!opWorkspace || !appPath) return undefined
|
|
{
|
|
const history = await AppService.getAppHistoryByPath({
|
|
workspace: opWorkspace,
|
|
path: appPath,
|
|
page,
|
|
perPage: VERSION_PAGE_SIZE
|
|
})
|
|
// Head is the version the payload beside this list came from; see FlowBuilder.
|
|
const head = deployedVersionShown ?? history[0]?.version
|
|
// No ordinal: the list arrives a page at a time, so a number counted within one
|
|
// would rename versions as more load.
|
|
return history.map((h) => {
|
|
const detail = [
|
|
h.created_by,
|
|
h.created_at ? new Date(h.created_at).toLocaleString() : undefined,
|
|
h.deployment_msg
|
|
].filter(Boolean)
|
|
const isHead = h.version === head
|
|
return {
|
|
id: String(h.version),
|
|
label: `${h.version}${isHead ? ' · latest' : ''}`,
|
|
subtitle: detail.length ? detail.join(' · ') : undefined,
|
|
isHead
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
/** Names the deployed side when the picker has no list to label it with — both are
|
|
* read in `syncWithDeployed`, which succeeds independently of the history. */
|
|
function deployedVersionLabel(): string | undefined {
|
|
if (deployedVersionShown == null) return undefined
|
|
return `Deployed ${deployedVersionShown}${deployedBy ? ` by ${deployedBy}` : ''} · latest`
|
|
}
|
|
|
|
/** The first page, best-effort: losing it costs the picker, not the diff. */
|
|
async function deployedVersionOptions() {
|
|
try {
|
|
return await fetchVersionPage(1)
|
|
} catch {
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** Hands the drawer the next page each time the reader asks for one. The page number
|
|
* belongs to this item's history, so it lives here — and only moves once a page has
|
|
* actually arrived, or a failed request would skip it. */
|
|
function moreVersionsLoader() {
|
|
let loaded = 1
|
|
return async () => {
|
|
const page = await fetchVersionPage(loaded + 1)
|
|
loaded += 1
|
|
return page
|
|
}
|
|
}
|
|
|
|
/** The opening this editor claimed last. A path change remounts this editor while the
|
|
* drawer stays mounted, so its teardown hands that opening back rather than leaving
|
|
* the drawer on the item the user left. */
|
|
let lastOpening: number | undefined = undefined
|
|
onDestroy(() => {
|
|
if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening)
|
|
})
|
|
|
|
export async function openDiffDrawer() {
|
|
if (!savedApp) {
|
|
return
|
|
}
|
|
// The fetches below are awaited, so a reopen (or a path change, which remounts
|
|
// this editor but not the drawer) while they run must not have the older one
|
|
// land last. The drawer counts the openings for that reason.
|
|
const opening = diffDrawer?.beginOpening()
|
|
lastOpening = opening
|
|
if (opening == null) return
|
|
|
|
// deployedValue should be syncronized when we open Diff
|
|
await syncWithDeployed(opening)
|
|
|
|
// Blanking the drawer belongs to the opening that will fill it.
|
|
if (!diffDrawer?.ownsOpening(opening)) return
|
|
diffDrawer.openDrawer(opening)
|
|
const versions = await deployedVersionOptions()
|
|
if (!diffDrawer?.ownsOpening(opening)) return
|
|
diffDrawer.setDiff(
|
|
{
|
|
mode: 'normal',
|
|
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
|
|
deployedLabel: deployedVersionLabel(),
|
|
versions,
|
|
loadMoreVersions: moreVersionsLoader(),
|
|
onTakeLatest,
|
|
draftBase: draftBaseVersion,
|
|
deployedHead: deployedVersionShown != null ? String(deployedVersionShown) : undefined,
|
|
loadVersion: async (id) => {
|
|
const v = await AppService.getAppByVersion({ workspace: opWorkspace!, id: Number(id) })
|
|
// Same normalization as `syncWithDeployed`, so switching versions doesn't
|
|
// reintroduce the post-deploy noise the head side already strips.
|
|
return replaceFalseWithUndefined(stripRawAppDiffNoise(v as any))
|
|
},
|
|
current: currentDiffValue
|
|
},
|
|
opening
|
|
)
|
|
}
|
|
|
|
async function updateApp(npath: string) {
|
|
if (!app) {
|
|
sendUserToast(`App hasn't been loaded yet`, true)
|
|
return
|
|
}
|
|
const { js, css } = await getBundle()
|
|
await computeTriggerables()
|
|
if (!policy.execution_mode) {
|
|
policy.execution_mode = 'publisher'
|
|
}
|
|
const deployed = await AppService.updateAppRaw({
|
|
workspace: opWorkspace!,
|
|
path: appPath!,
|
|
formData: {
|
|
app: {
|
|
value: app!,
|
|
summary: summary,
|
|
policy,
|
|
path: npath,
|
|
deployment_message: deploymentMsg,
|
|
preserve_on_behalf_of: preserveOnBehalfOf || undefined,
|
|
// custom_path requires admin so to accept update without it, we need to send as undefined when non-admin (when undefined, it will be ignored)
|
|
// it also means that customPath needs to be set to '' instead of undefined to unset it (when admin)
|
|
custom_path:
|
|
actingUser?.is_admin || actingUser?.is_super_admin ? (customPath ?? '') : undefined,
|
|
labels
|
|
},
|
|
js,
|
|
css
|
|
}
|
|
})
|
|
invalidateWorkspacePaths(opWorkspace!)
|
|
savedApp = {
|
|
summary: summary,
|
|
value: structuredClone(stateSnapshot(app)),
|
|
path: npath,
|
|
policy,
|
|
custom_path: customPath,
|
|
labels: $state.snapshot(labels)
|
|
}
|
|
const head = await AppService.getAppLatestVersion({
|
|
workspace: opWorkspace!,
|
|
path: npath
|
|
})
|
|
// The deploy's own answer is the next draft's base; `version` is what is deployed
|
|
// now, and the two differ when another deploy landed beside this one. The route
|
|
// owns this prop and re-pushes `parentVersion ?? head` as soon as `onDeploy` returns.
|
|
version = head?.version
|
|
|
|
closeSaveDrawer()
|
|
sendUserToast('App deployed successfully')
|
|
// Bracketed + flushed (see createApp).
|
|
if (!inSessionPane && $operatingWorkspace) {
|
|
discardDraftAfterDeploy({
|
|
workspace: $operatingWorkspace,
|
|
itemKind: 'raw_app',
|
|
path: appPath
|
|
})
|
|
}
|
|
if (appPath !== npath) {
|
|
onSavedNewAppPath?.(npath)
|
|
}
|
|
onDeploy?.({
|
|
path: npath,
|
|
version: deployed.version,
|
|
head: version,
|
|
headBy: head?.created_by,
|
|
headAt: head?.created_at
|
|
})
|
|
}
|
|
|
|
async function setPublishState(message?: string) {
|
|
await computeTriggerables()
|
|
await AppService.updateApp({
|
|
workspace: opWorkspace!,
|
|
path: appPath,
|
|
requestBody: { policy }
|
|
})
|
|
if (message) {
|
|
sendUserToast(message)
|
|
} else if (policy.execution_mode == 'anonymous') {
|
|
sendUserToast('App require no login to be accessed')
|
|
} else {
|
|
sendUserToast('App require login and read-access')
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
saveDrawerOpen = true
|
|
return
|
|
}
|
|
|
|
let onLatest = $state(true)
|
|
/** The last comparison could not read the head, so the confirmation it raises is
|
|
* caution and not an observed deploy. Cleared by the next reading comparison. */
|
|
let headUnknown = $state(false)
|
|
async function compareVersions() {
|
|
if (version === undefined) {
|
|
return
|
|
}
|
|
try {
|
|
const appVersion = await AppService.getAppLatestVersion({
|
|
workspace: opWorkspace!,
|
|
path: appPath
|
|
})
|
|
onLatest = appVersion?.version === undefined || version === appVersion?.version
|
|
headUnknown = false
|
|
} catch (e) {
|
|
console.error('Error comparing versions', e)
|
|
// The head is what this compares against, so an unanswered read is not
|
|
// evidence of being current: confirm, as an unclaimable deploy does, and say
|
|
// that is why rather than claiming a version that was never seen.
|
|
onLatest = false
|
|
headUnknown = true
|
|
}
|
|
}
|
|
|
|
const mod = isMac() ? '⌘' : 'Ctrl+'
|
|
|
|
let moreItems = $derived([
|
|
...(compactTopbar
|
|
? [
|
|
{
|
|
displayName: `Jobs (${jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})`,
|
|
icon: Bug,
|
|
action: () => (jobsDrawerOpen = true),
|
|
separatorBottom: true
|
|
}
|
|
]
|
|
: []),
|
|
{
|
|
displayName: 'Undo',
|
|
icon: Undo,
|
|
action: () => onUndo?.(),
|
|
disabled: !canUndo,
|
|
shortcut: `${mod}Z`
|
|
},
|
|
{
|
|
displayName: 'Redo',
|
|
icon: Redo,
|
|
action: () => onRedo?.(),
|
|
disabled: !canRedo,
|
|
shortcut: `${mod}⇧Z`,
|
|
separatorBottom: true
|
|
},
|
|
{
|
|
displayName: 'Deployment history',
|
|
icon: History,
|
|
action: () => {
|
|
historyBrowserDrawerOpen = true
|
|
},
|
|
disabled: !savedApp
|
|
},
|
|
{
|
|
displayName: 'Export',
|
|
icon: FileJson,
|
|
action: () => {
|
|
appExport?.open(app)
|
|
}
|
|
},
|
|
{
|
|
displayName: 'Edit in YAML',
|
|
icon: FileJson,
|
|
action: () => onOpenYamlEditor?.()
|
|
},
|
|
{
|
|
displayName: 'Record demo',
|
|
icon: Circle,
|
|
action: () => {
|
|
recordDrawer?.openDrawer()
|
|
},
|
|
disabled: !savedApp
|
|
}
|
|
])
|
|
|
|
let customPath = $state(savedApp?.custom_path)
|
|
let customPathError = $state('')
|
|
let labels = $state(untrack(() => initialLabels))
|
|
|
|
let jobsDrawerOpen = $state(false)
|
|
|
|
let app = $derived(files ? { runnables: runnables, files, data } : undefined)
|
|
|
|
// Editor-side value for diffing/comparison against the deployed app, with the
|
|
// same noise stripped as the deployed side (see stripRawAppDiffNoise).
|
|
let currentDiffValue = $derived(
|
|
stripRawAppDiffNoise({
|
|
summary: summary,
|
|
value: app,
|
|
path: newEditedPath || savedApp?.path,
|
|
policy,
|
|
custom_path: customPath,
|
|
labels
|
|
})
|
|
)
|
|
|
|
$effect(() => {
|
|
saveDrawerOpen && compareVersions()
|
|
})
|
|
</script>
|
|
|
|
<DeployOverrideConfirmationModal
|
|
{deployedBy}
|
|
{confirmCallback}
|
|
bind:open
|
|
{diffDrawer}
|
|
claimOpening={() => (lastOpening = diffDrawer?.beginOpening())}
|
|
{headUnknown}
|
|
bind:deployedValue
|
|
currentValue={currentDiffValue}
|
|
/>
|
|
|
|
<Drawer bind:open={saveDrawerOpen} size="800px">
|
|
<DrawerContent title="Deploy" on:close={() => closeSaveDrawer()}>
|
|
{#snippet actions()}
|
|
<div class="flex flex-row gap-2">
|
|
<Button
|
|
variant="default"
|
|
disabled={!savedApp || newApp}
|
|
on:click={async () => {
|
|
if (!savedApp || newApp) {
|
|
return
|
|
}
|
|
// The other entry point into the same drawer, so it takes an opening too.
|
|
const opening = diffDrawer?.beginOpening()
|
|
lastOpening = opening
|
|
if (opening == null) return
|
|
// deployedValue should be syncronized when we open Diff
|
|
await syncWithDeployed(opening)
|
|
|
|
if (!diffDrawer?.ownsOpening(opening)) return
|
|
saveDrawerOpen = false
|
|
diffDrawer.openDrawer(opening)
|
|
diffDrawer.setDiff(
|
|
{
|
|
mode: 'normal',
|
|
deployed: deployedValue ?? stripRawAppDiffNoise(savedApp),
|
|
current: currentDiffValue,
|
|
button: {
|
|
text: 'Looks good, deploy',
|
|
onClick: () => {
|
|
if (newApp || appPath == '') {
|
|
createApp(newEditedPath)
|
|
} else {
|
|
handleUpdateApp(newEditedPath)
|
|
}
|
|
}
|
|
}
|
|
},
|
|
opening
|
|
)
|
|
}}
|
|
>
|
|
<div class="flex flex-row gap-2 items-center">
|
|
<DiffIcon size={14} />
|
|
Diff
|
|
</div>
|
|
</Button>
|
|
<Button
|
|
variant="accent"
|
|
unifiedSize="md"
|
|
startIcon={{ icon: Save }}
|
|
disabled={pathError != '' || customPathError != '' || app == undefined}
|
|
on:click={() => {
|
|
if (newApp || appPath == '') {
|
|
createApp(newEditedPath)
|
|
} else {
|
|
handleUpdateApp(newEditedPath)
|
|
}
|
|
}}
|
|
>
|
|
Deploy
|
|
</Button>
|
|
</div>
|
|
{/snippet}
|
|
|
|
<AppEditorHeaderDeploy
|
|
{newPath}
|
|
{newApp}
|
|
{policy}
|
|
{setPublishState}
|
|
{appPath}
|
|
{onLatest}
|
|
{headUnknown}
|
|
{savedApp}
|
|
rawApp
|
|
operatingWorkspace={opWorkspace}
|
|
bind:summary
|
|
bind:customPath
|
|
bind:deploymentMsg
|
|
bind:customPathError
|
|
bind:pathError
|
|
bind:newEditedPath
|
|
bind:preserveOnBehalfOf
|
|
bind:labels
|
|
/>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<Drawer bind:open={historyBrowserDrawerOpen} size="1200px">
|
|
<DrawerContent title="Deployment History" on:close={() => (historyBrowserDrawerOpen = false)}>
|
|
<DeploymentHistory on:restore={(e) => onRestore?.(e.detail)} {appPath} />
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<!-- Full screen: the demo is recorded at the size it replays at. -->
|
|
<Drawer bind:this={recordDrawer} size="100vw">
|
|
<DrawerContent
|
|
title="Record a demo — {savedApp?.path ?? appPath}"
|
|
on:close={() => recordDrawer?.closeDrawer()}
|
|
>
|
|
<div class="flex flex-col h-full min-h-0 gap-2">
|
|
<div class="text-xs text-secondary flex flex-col gap-1">
|
|
<span>
|
|
Use the app the way someone else would — each interaction becomes a step, and the
|
|
recording captures the page as they would see it. Passwords are masked; add
|
|
<span class="font-mono">data-wm-no-record</span> to anything else that should stay out.
|
|
</span>
|
|
<span>
|
|
Stop recording, then <b>Download</b> the JSON. It is self-contained: open it on
|
|
<a href="{base}/replay" target="_blank" rel="noreferrer" class="text-blue-500 underline">
|
|
{base}/replay
|
|
</a>
|
|
— a public page that needs no login and can be embedded in an iframe. Host the JSON anywhere
|
|
it can be fetched (S3, GitHub raw, your docs site) and link
|
|
<span class="font-mono">{base}/replay?src=<url></span> to have it load itself. Windmill
|
|
keeps no copy.
|
|
</span>
|
|
</div>
|
|
<div class="flex-1 min-h-0">
|
|
{#if recordDrawer}
|
|
<!-- opWorkspace, not the selected one: a session edits an app that may
|
|
live in another workspace, and recording must load and run it there. -->
|
|
<RawAppRecordSession workspace={opWorkspace ?? ''} path={savedApp?.path ?? appPath} />
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</DrawerContent>
|
|
</Drawer>
|
|
|
|
<AppJobsDrawer
|
|
bind:open={jobsDrawerOpen}
|
|
on:clear={() => {
|
|
jobs = []
|
|
}}
|
|
on:clearErrors={() => {
|
|
console.log('todo clear errors')
|
|
}}
|
|
{jobs}
|
|
hasErrors={false}
|
|
{jobsById}
|
|
errorByComponent={{}}
|
|
/>
|
|
|
|
{#if ownsPageHeader}
|
|
<!-- The editor's own top bar is the page header on this route: the app's path and summary are
|
|
the breadcrumb's, and everything else the bar carried rides along as the header's actions.
|
|
`separator="none"`: the splitpanes below draw their own top line, and the bar's edge would
|
|
be a second one on top of it. -->
|
|
<PageHeaderContent
|
|
item={{
|
|
// The path being edited, not the stored one: a brand-new app is parked at a
|
|
// `draft_<uuid>` placeholder, and the trail would name that instead of the path Deploy
|
|
// will create — which is why `newEditedPath` refuses the placeholder. Frozen while the
|
|
// pen's popover is open so the trail holds still as the user types.
|
|
kind: 'app',
|
|
path: pathSnapshot ?? (newEditedPath || appPath || newPath || undefined),
|
|
summaryContent: appSummary
|
|
}}
|
|
actions={rawAppHeaderActions}
|
|
contexts={headerContexts}
|
|
separator="none"
|
|
/>
|
|
{:else}
|
|
<div
|
|
bind:clientWidth={topbarWidth}
|
|
class="flex flex-row justify-between gap-2 gap-y-2 px-2 items-center overflow-y-visible overflow-x-auto shrink-0 {condensedHeader
|
|
? 'max-h-9 h-9'
|
|
: 'max-h-12 h-12'}"
|
|
>
|
|
<!-- Identity block: shrinks/truncates first so the cloud indicator and the
|
|
action buttons stay visible. Without min-w-0 the breadcrumb + summary
|
|
overflow this box on narrow widths and get overlapped by the (formerly
|
|
un-pinned) action group, hiding the autosave cloud. -->
|
|
<div class="flex flex-row gap-2 items-center min-w-0">
|
|
{@render sidebarToggle()}
|
|
<div class="min-w-0 overflow-hidden">
|
|
<EditorHeader
|
|
bind:summary
|
|
bind:path={newEditedPath}
|
|
savedPath={appPath || newPath || undefined}
|
|
kind="app"
|
|
raw_app
|
|
hidePath={condensedHeader}
|
|
workspaceId={autosaveWorkspace}
|
|
onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))}
|
|
/>
|
|
</div>
|
|
{@render autosaveIndicator()}
|
|
</div>
|
|
|
|
{@render awarenessMark()}
|
|
<div class="flex flex-row gap-2 justify-end items-center overflow-visible shrink-0">
|
|
{@render rawAppActions()}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#snippet sidebarToggle()}
|
|
{#if onToggleSidebar}
|
|
<Button
|
|
unifiedSize="sm"
|
|
variant="subtle"
|
|
iconOnly
|
|
startIcon={{ icon: sidebarCollapsed ? PanelLeft : PanelLeftClose }}
|
|
title={`${sidebarCollapsed ? 'Expand' : 'Collapse'} file sidebar (${isMac() ? '⌘' : 'Ctrl+'}B)`}
|
|
on:click={() => onToggleSidebar?.()}
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet autosaveIndicator()}
|
|
{#if opWorkspace && indicatorPath !== undefined}
|
|
<AutosaveIndicator
|
|
workspace={opWorkspace}
|
|
itemKind="raw_app"
|
|
path={indicatorPath}
|
|
draftOnly={newApp}
|
|
{onResetToDeployed}
|
|
{loadedFromDraft}
|
|
{othersDraftsCount}
|
|
{onOpenOthersDrafts}
|
|
/>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet awarenessMark()}
|
|
{#if $enterpriseLicense && appPath != '' && !inSessionPane}
|
|
<div class="shrink-0">
|
|
<Awareness />
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
|
|
{#snippet appSummary()}
|
|
<!-- Not edited in place: the pen beside it opens the summary and the path together,
|
|
so the band reads as a name rather than a form. `title` for one it truncates. -->
|
|
<div class="group flex items-center gap-1 min-w-0">
|
|
<span
|
|
class="min-w-0 truncate text-xs {emptyString(summary)
|
|
? 'text-tertiary italic font-normal'
|
|
: 'font-medium text-emphasis'}"
|
|
title={summary}>{emptyString(summary) ? 'Add a summary...' : summary}</span
|
|
>
|
|
<PathEditPopover
|
|
penVisibility={emptyString(summary) ? 'always' : 'hover'}
|
|
bind:summary
|
|
bind:path={newEditedPath}
|
|
bind:snapshotPath={pathSnapshot}
|
|
savedPath={appPath || undefined}
|
|
kind="app"
|
|
workspaceId={autosaveWorkspace}
|
|
/>
|
|
</div>
|
|
{/snippet}
|
|
|
|
{#snippet rawAppHeaderActions()}
|
|
<!-- The bar's own order kept: the file sidebar, how the draft is doing, who else is here, then
|
|
what to do with the app. The sidebar's toggle is the only way back once it is folded — the
|
|
collapsed sidebar renders nothing of its own — so it stays on the bar at every width. -->
|
|
{@render sidebarToggle()}
|
|
{@render autosaveIndicator()}
|
|
{@render awarenessMark()}
|
|
{@render rawAppActions()}
|
|
{/snippet}
|
|
|
|
{#snippet rawAppActions()}
|
|
<DropdownV2 items={moreItems} class="h-auto">
|
|
{#snippet buttonReplacement()}
|
|
<Button
|
|
nonCaptureEvent
|
|
unifiedSize={headerBtnSize}
|
|
variant="subtle"
|
|
startIcon={{ icon: EllipsisVertical }}
|
|
iconOnly
|
|
></Button>
|
|
{/snippet}
|
|
</DropdownV2>
|
|
|
|
<!-- A disabled <button> fires no pointer events, so a title/tooltip on it
|
|
never shows on hover. pointer-events-none on the button lets the hover
|
|
reach this titled wrapper instead. -->
|
|
<div
|
|
title={newApp || savedApp?.no_deployed === true
|
|
? 'Deploy this app once to compare against the deployed version'
|
|
: 'Diff'}
|
|
class={!savedApp || newApp || savedApp?.no_deployed === true
|
|
? 'flex cursor-not-allowed'
|
|
: 'flex'}
|
|
>
|
|
<Button
|
|
variant="default"
|
|
unifiedSize={headerBtnSize}
|
|
on:click={() => openDiffDrawer()}
|
|
disabled={!savedApp || newApp || savedApp?.no_deployed === true}
|
|
btnClasses={!savedApp || newApp || savedApp?.no_deployed === true
|
|
? 'pointer-events-none'
|
|
: undefined}
|
|
iconOnly={compactTopbar}
|
|
title={newApp || savedApp?.no_deployed === true
|
|
? 'Deploy this app once to compare against the deployed version'
|
|
: 'Diff'}
|
|
startIcon={{ icon: DiffIcon }}
|
|
>
|
|
Diff
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="{compactTopbar ? 'hidden' : 'hidden md:inline'} relative overflow-visible">
|
|
<Button
|
|
on:click={() => {
|
|
jobsDrawerOpen = true
|
|
}}
|
|
color="light"
|
|
unifiedSize={headerBtnSize}
|
|
variant="default"
|
|
btnClasses="relative"
|
|
>
|
|
<div class="flex flex-row gap-1 items-center">
|
|
<Bug size={14} />
|
|
<div>Jobs</div>
|
|
|
|
<div class="text-2xs text-primary">({jobs?.length > 99 ? '99+' : (jobs?.length ?? 0)})</div>
|
|
</div>
|
|
</Button>
|
|
</div>
|
|
<!-- Draws nothing in the bar: it is the export drawer, opened from the menu's Export entry
|
|
through this instance. -->
|
|
<AppExportButton bind:this={appExport} />
|
|
{#if !phoneTopbar}
|
|
<OpenInSessionButton source={sessionOpen}>
|
|
{#snippet fallback()}
|
|
<Button
|
|
unifiedSize={headerBtnSize}
|
|
variant="default"
|
|
onClick={() => aiChatManager.toggleOpen()}
|
|
startIcon={{ icon: WandSparkles }}
|
|
iconOnly
|
|
btnClasses={AIBtnClasses('default')}
|
|
>
|
|
AI
|
|
</Button>
|
|
{/snippet}
|
|
</OpenInSessionButton>
|
|
{/if}
|
|
<Button
|
|
loading={loading.save}
|
|
startIcon={{ icon: Save }}
|
|
on:click={save}
|
|
unifiedSize={headerBtnSize}
|
|
variant="accent"
|
|
dropdownItems={appPath != ''
|
|
? () => [
|
|
// `appPath`, not the live edited path: a rename that has not been deployed yet
|
|
// would land on a details page for something that does not exist.
|
|
...(newApp
|
|
? []
|
|
: [
|
|
{
|
|
label: 'Exit & see details',
|
|
onClick: () => onDetails?.({ path: appPath })
|
|
}
|
|
]),
|
|
{
|
|
label: 'Fork',
|
|
onClick: () => {
|
|
window.open(`/apps/add?template=${appPath}`)
|
|
}
|
|
},
|
|
...(!isCloudHosted() && editInForkAllowed(opWorkspace, $userWorkspaces)
|
|
? [
|
|
{
|
|
label: editInForkLabel(opWorkspace, $userWorkspaces),
|
|
onClick: () => {
|
|
openEditInFork('raw_app', appPath, opWorkspace)
|
|
}
|
|
}
|
|
]
|
|
: [])
|
|
]
|
|
: undefined}
|
|
>
|
|
Deploy
|
|
</Button>
|
|
{/snippet}
|