perf: shrink the module graph that gates first paint in dev (#11373)

* perf: shrink the module graph that gates first paint in dev

* docs: drop the stale synchronous-icons claim on the import card

* fix: replay search opened before its modal loads, guard lazy icons

* fix: only intercept search before load where the modal mounts

* perf: mount app-shell modals on first open and keep monaco off the shell

* perf: load the icon map on first read, not at module evaluation

* fix: report stale chunks with a reload toast, guard the home page against monaco
This commit is contained in:
Diego Imbert
2026-09-27 10:09:45 +00:00
committed by GitHub
parent 649c43e7c1
commit 47525b211a
40 changed files with 614 additions and 352 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
import { argsToJsonPayload } from '$lib/schema'
import JobLoader from '$lib/components/JobLoader.svelte'
import { Button } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import LogPanel from '$lib/components/scriptEditor/LogPanel.svelte'
import {
type CompletedJob,
@@ -22,7 +22,6 @@
Database,
Loader2
} from 'lucide-svelte'
import DucklakeResultPreview from './assets/AssetGraph/DucklakeResultPreview.svelte'
import DataTestsResult from './DataTestsResult.svelte'
import Portal from '$lib/components/Portal.svelte'
import DisplayResultControlBar from './DisplayResultControlBar.svelte'
@@ -1105,11 +1104,15 @@
/>
{#if showMaterializedPreview}
<div class="border rounded-md h-80 min-h-0 overflow-hidden">
<DucklakeResultPreview
assetUri={m.materialized}
partition={m.partition}
class="h-full"
/>
<!-- Dynamic: the DuckDB preview reaches ~450 modules (data table editor, low-code
components), and every page that shows a job result imports this file. -->
{#await import('./assets/AssetGraph/DucklakeResultPreview.svelte') then DucklakeResultPreview}
<DucklakeResultPreview.default
assetUri={m.materialized}
partition={m.partition}
class="h-full"
/>
{/await}
</div>
{/if}
</div>
@@ -307,7 +307,6 @@
import { MinusIcon, SearchIcon } from 'lucide-svelte'
import { assignObjInPlace, clone } from '$lib/utils'
import GenericDropdown from './select/GenericDropdown.svelte'
import SimpleEditor from './SimpleEditor.svelte'
import TaggedTextInput from './TaggedTextInput.svelte'
import { DebouncedTempValue, useTransformedSyncedValue } from '$lib/svelte5Utils.svelte'
import { untrack } from 'svelte'
@@ -885,20 +884,23 @@
</div>
{:else if filter.type === 'string' && filter.format === 'json'}
<div class="px-2 pb-2">
<SimpleEditor
autofocus={String(value[currentTag!] ?? '').length === 0}
lang="json"
autoHeight
small
bind:code={
() => String(value[currentTag!] ?? ''),
(v) => {
setValueForCurrentTag(v ?? '')
taggedTextInput?.preventCursorMoveOnNextSync()
<!-- Dynamic: monaco would otherwise load with every list page that has a searchbar. -->
{#await import('./SimpleEditor.svelte') then SimpleEditor}
<SimpleEditor.default
autofocus={String(value[currentTag!] ?? '').length === 0}
lang="json"
autoHeight
small
bind:code={
() => String(value[currentTag!] ?? ''),
(v) => {
setValueForCurrentTag(v ?? '')
taggedTextInput?.preventCursorMoveOnNextSync()
}
}
}
class="border border-border-light rounded min-h-[4rem]"
/>
class="border border-border-light rounded min-h-[4rem]"
/>
{/await}
</div>
{:else if filter.type === 'string'}
{#if menuItems.length}
@@ -1,6 +1,6 @@
<script>
import { Boxes, FileText, FolderOpen } from 'lucide-svelte'
import { appIconComponent } from './icons'
import { appIconComponent } from './icons/appIcon.svelte'
/**
* @typedef {Object} Props
* @property {any} name
@@ -37,8 +37,8 @@
const hubProjectUrl = $derived(`//${hubHost}/projects/${project.slug}`)
// Resolved locally rather than fetched: these are Windmill's own bundled icons, so the
// card draws them synchronously instead of waiting on the hub — and keeps working on a
// hub that refuses uncredentialed reads.
// card does not wait on the hub — and keeps working on a hub that refuses uncredentialed
// reads.
const icons = $derived(
project.iconApps
.slice(0, 4)
@@ -10,7 +10,7 @@
import Select from './select/Select.svelte'
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
import DropdownV2 from './DropdownV2.svelte'
import { appIconComponent } from './icons'
import { appIconComponent } from './icons/appIcon.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
+27 -22
View File
@@ -30,7 +30,6 @@
import TextInput from '$lib/components/text_input/TextInput.svelte'
import RunChart from '$lib/components/RunChart.svelte'
import JobRunsPreview from '$lib/components/runs/JobRunsPreview.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import RunsTable from '$lib/components/runs/RunsTable.svelte'
@@ -50,9 +49,7 @@
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte'
import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils'
import BatchReRunOptionsPane, {
type BatchReRunOptions
} from '$lib/components/runs/BatchReRunOptionsPane.svelte'
import type { BatchReRunOptions } from '$lib/components/runs/BatchReRunOptionsPane.svelte'
import { untrack } from 'svelte'
import { page } from '$app/state'
import Select from '$lib/components/select/Select.svelte'
@@ -631,7 +628,9 @@
{#if selectedIds[0] === '-'}
<div class="p-4">There is no information available for this job</div>
{:else}
<JobRunsPreview id={selectedIds[0]} workspace={selectedWorkspace} />
{#await import('$lib/components/runs/JobRunsPreview.svelte') then JobRunsPreview}
<JobRunsPreview.default id={selectedIds[0]} workspace={selectedWorkspace} />
{/await}
{/if}
{/if}
</DrawerContent>
@@ -1131,27 +1130,33 @@
</div>
</div>
{:else if batchRerunOptionsIsOpen}
<BatchReRunOptionsPane
{selectedIds}
onCancel={() => (
(batchRerunOptionsIsOpen = false),
(manualSelectionMode = undefined)
)}
onConfirm={async (options) => {
await onReRunSelectedJobs(options)
}}
/>
{#await import('$lib/components/runs/BatchReRunOptionsPane.svelte') then BatchReRunOptionsPane}
<BatchReRunOptionsPane.default
{selectedIds}
onCancel={() => (
(batchRerunOptionsIsOpen = false),
(manualSelectionMode = undefined)
)}
onConfirm={async (options) => {
await onReRunSelectedJobs(options)
}}
/>
{/await}
{:else if selectedIds.length === 1}
{#if selectedIds[0] === '-'}
<div class="p-4">There is no information available for this job</div>
{:else}
<JobRunsPreview
id={selectedIds[0]}
workspace={selectedWorkspace}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByWorker={filterByWorker}
onResolutionChanged={() => jobsLoader?.loadJobs(true, true)}
/>
<!-- Dynamic, like the batch re-run pane: both only show once jobs are selected
and each statically reaches hundreds of modules (flow graph, monaco). -->
{#await import('$lib/components/runs/JobRunsPreview.svelte') then JobRunsPreview}
<JobRunsPreview.default
id={selectedIds[0]}
workspace={selectedWorkspace}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByWorker={filterByWorker}
onResolutionChanged={() => jobsLoader?.loadJobs(true, true)}
/>
{/await}
{/if}
{:else if selectedIds.length > 1}
<div
@@ -1,5 +1,5 @@
<script lang="ts">
import { WindmillIcon } from './icons'
import WindmillIcon from './icons/WindmillIcon.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Building } from 'lucide-svelte'
@@ -1,5 +1,5 @@
<script lang="ts">
import { WindmillIcon2 } from './icons'
import WindmillIcon2 from './icons/WindmillIcon2.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import { Building } from 'lucide-svelte'
@@ -23,7 +23,6 @@
import { WorkspaceService, type AIProvider, type AIProviderModel } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { base } from '$lib/base'
import AIPromptsModal from '$lib/components/settings/AIPromptsModal.svelte'
import { getAiChatManager } from './aiChatManagerContext'
import { thinkingPreferences } from './thinkingPreferences.svelte'
import {
@@ -95,6 +94,11 @@
// ---- prompt parameters (User / Workspace custom prompts) ----
let mode = $derived(aiChatManager.mode)
let modalOpen = $state(false)
// Sticky, so the prompts modal mounts on first open and then stays for its close animation.
let promptsModalRequested = $state(false)
$effect(() => {
if (modalOpen) promptsModalRequested = true
})
let modalScope = $state<'user' | 'workspace'>('user')
let customPrompts = $state<Record<string, string>>({})
let initialPrompt = $state('')
@@ -285,20 +289,24 @@
<ChatModelSettings {config} />
{#if promptSettings}
<AIPromptsModal
bind:open={modalOpen}
bind:customPrompts
scope={modalScope}
modes={[activeMode]}
readOnly={modalReadOnly}
{readOnlyReason}
onSave={modalReadOnly ? undefined : save}
onReset={reset}
{hasChanges}
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
target="body"
fixedHeight="sm"
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
/>
{#if promptSettings && promptsModalRequested}
<!-- Dynamic, on first open: statically, the prompts modal pulls the whole chat manager into
the home page. -->
{#await import('$lib/components/settings/AIPromptsModal.svelte') then AIPromptsModal}
<AIPromptsModal.default
bind:open={modalOpen}
bind:customPrompts
scope={modalScope}
modes={[activeMode]}
readOnly={modalReadOnly}
{readOnlyReason}
onSave={modalReadOnly ? undefined : save}
onReset={reset}
{hasChanges}
title={modalScope === 'user' ? 'User AI prompt' : 'Workspace AI prompt'}
target="body"
fixedHeight="sm"
settingsHref={isAdmin ? AI_SETTINGS_HREF : undefined}
/>
{/await}
{/if}
@@ -1,7 +1,6 @@
<script lang="ts">
import { classNames } from '$lib/utils'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import AiChat from './AIChat.svelte'
import SessionsBetaBanner from '$lib/components/sessions/SessionsBetaBanner.svelte'
import { zIndexes } from '$lib/zIndexes'
import { userStore, workspaceStore } from '$lib/stores'
@@ -127,7 +126,10 @@
class={`flex flex-col min-h-0 z-[${zIndexes.aiChat}]`}
>
<div class="flex-1 min-h-0">
<AiChat />
<!-- Dynamic: this layout wraps every workspace page, and the chat reaches ~100 modules. -->
{#await import('./AIChat.svelte') then AiChat}
<AiChat.default />
{/await}
</div>
{#if showSessionsBetaBanner}
<SessionsBetaBanner variant="legacy" />
@@ -1,202 +1,33 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte'
import type { Component } from 'svelte'
import { nextAnimationFrame } from '$lib/utils/paint'
import { Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import { Loader2 } from 'lucide-svelte'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import { onDestroy } from 'svelte'
import { registerToolDisplayActionHandler } from './createdResourceActions.svelte'
import type { CreatedResourceTriggerKind, ToolDisplayAction } from './shared'
import type CreatedResourceActionDrawersInner from './CreatedResourceActionDrawersInner.svelte'
type DrawerKey = 'schedule' | CreatedResourceTriggerKind
type EditorHandle = {
openEdit: (path: string, isFlow: boolean) => Promise<void>
}
type EditorComponent = Component<
{
useDrawer?: boolean
onUpdate?: (path?: string) => void
},
EditorHandle
>
type EditorModule = { default: unknown }
type DrawerConfig = {
label: string
load: () => Promise<EditorModule>
}
type ActiveDrawerState = {
id: number
key: DrawerKey
path: string
promise: Promise<EditorModule>
}
const DRAWER_SIZE = '800px'
let drawer: Drawer | undefined = $state(undefined)
let editor: EditorHandle | undefined = $state(undefined)
let activeDrawer: ActiveDrawerState | undefined = $state(undefined)
let nextActiveDrawerId = 0
let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const drawerConfigs: Record<DrawerKey, DrawerConfig> = {
schedule: {
label: 'schedule',
load: () => import('$lib/components/triggers/schedules/ScheduleEditorInner.svelte')
},
http: {
label: 'HTTP trigger',
load: () => import('$lib/components/triggers/http/RouteEditorInner.svelte')
},
websocket: {
label: 'WebSocket trigger',
load: () => import('$lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte')
},
postgres: {
label: 'Postgres trigger',
load: () => import('$lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte')
},
kafka: {
label: 'Kafka trigger',
load: () => import('$lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte')
},
nats: {
label: 'NATS trigger',
load: () => import('$lib/components/triggers/nats/NatsTriggerEditorInner.svelte')
},
mqtt: {
label: 'MQTT trigger',
load: () => import('$lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte')
},
amqp: {
label: 'AMQP trigger',
load: () => import('$lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte')
},
sqs: {
label: 'SQS trigger',
load: () => import('$lib/components/triggers/sqs/SqsTriggerEditorInner.svelte')
},
gcp: {
label: 'GCP Pub/Sub trigger',
load: () => import('$lib/components/triggers/gcp/GcpTriggerEditorInner.svelte')
},
azure: {
label: 'Azure Event Grid trigger',
load: () => import('$lib/components/triggers/azure/AzureTriggerEditorInner.svelte')
},
email: {
label: 'Email trigger',
load: () => import('$lib/components/triggers/email/EmailTriggerEditorInner.svelte')
}
}
const drawerTitle = $derived.by(() => {
const current = activeDrawer
return current ? `Edit ${drawerConfigs[current.key].label} ${current.path}` : 'Edit trigger'
// The handler registers during render, so the action is offered right away and a page
// that takes it over (the sessions panel) registers on top of it. The drawers themselves
// load on first use: they reach the resource and variable editors.
let inner: Promise<typeof import('./CreatedResourceActionDrawersInner.svelte')> | undefined =
$state()
let drawers: CreatedResourceActionDrawersInner | undefined = $state()
let resolveBound: (d: CreatedResourceActionDrawersInner) => void = () => {}
const bound = new Promise<CreatedResourceActionDrawersInner>((r) => (resolveBound = r))
$effect(() => {
if (drawers) resolveBound(drawers)
})
async function getEditor(request: ActiveDrawerState): Promise<EditorHandle | undefined> {
await request.promise
await tick()
await nextAnimationFrame()
if (activeDrawer?.id !== request.id) {
return undefined
}
if (!editor) {
await tick()
await nextAnimationFrame()
}
if (!editor) {
throw new Error(`${drawerConfigs[request.key].label} drawer is not ready`)
}
return editor
}
async function openCreatedResource(action: ToolDisplayAction) {
if (action.type !== 'open_created_resource') {
return
}
if (action.resource === 'resource') {
await resourceEditorDrawer?.initEdit(action.path)
return
}
if (action.resource === 'variable') {
await variableEditor?.editVariable(action.path)
return
}
const key = action.resource === 'schedule' ? 'schedule' : action.triggerKind
if (!key) {
throw new Error('Missing trigger kind')
}
if (activeDrawer?.key === key && activeDrawer.path === action.path && drawer?.isOpen()) {
activeDrawer = undefined
editor = undefined
drawer.closeDrawer()
return
}
const config = drawerConfigs[key]
const promise = activeDrawer?.key === key ? activeDrawer.promise : config.load()
if (activeDrawer?.key !== key) {
editor = undefined
}
const request: ActiveDrawerState = {
id: nextActiveDrawerId++,
key,
path: action.path,
promise
}
activeDrawer = request
drawer?.openDrawer()
const currentEditor = await getEditor(request)
if (!currentEditor) {
return
}
await currentEditor.openEdit(action.path, action.targetKind === 'flow')
}
function onInnerUpdate() {
drawer?.closeDrawer()
}
const unregisterCreatedResourceHandler = registerToolDisplayActionHandler(
'open_created_resource',
openCreatedResource
)
const unregister = registerToolDisplayActionHandler('open_created_resource', async (action) => {
inner ??= import('./CreatedResourceActionDrawersInner.svelte')
await inner
await (await bound).openCreatedResource(action)
})
onDestroy(() => {
unregisterCreatedResourceHandler()
unregister()
})
</script>
<Drawer bind:this={drawer} size={DRAWER_SIZE}>
<DrawerContent title={drawerTitle} on:close={() => drawer?.closeDrawer()}>
{#if activeDrawer}
{#key activeDrawer.key}
{#await activeDrawer.promise}
<div class="p-4 flex justify-center">
<Loader2 class="animate-spin" />
</div>
{:then Module}
{@const Editor = Module.default as EditorComponent}
<Editor bind:this={editor} useDrawer={false} onUpdate={onInnerUpdate} />
{/await}
{/key}
{/if}
</DrawerContent>
</Drawer>
<ResourceEditorDrawer bind:this={resourceEditorDrawer} />
<VariableEditor bind:this={variableEditor} />
{#if inner}
{#await inner then Inner}
<Inner.default bind:this={drawers} />
{/await}
{/if}
@@ -0,0 +1,192 @@
<script lang="ts">
import { tick } from 'svelte'
import type { Component } from 'svelte'
import { nextAnimationFrame } from '$lib/utils/paint'
import { Drawer } from '$lib/components/common'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import { Loader2 } from 'lucide-svelte'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import VariableEditor from '$lib/components/VariableEditor.svelte'
import type { CreatedResourceTriggerKind, ToolDisplayAction } from './shared'
type DrawerKey = 'schedule' | CreatedResourceTriggerKind
type EditorHandle = {
openEdit: (path: string, isFlow: boolean) => Promise<void>
}
type EditorComponent = Component<
{
useDrawer?: boolean
onUpdate?: (path?: string) => void
},
EditorHandle
>
type EditorModule = { default: unknown }
type DrawerConfig = {
label: string
load: () => Promise<EditorModule>
}
type ActiveDrawerState = {
id: number
key: DrawerKey
path: string
promise: Promise<EditorModule>
}
const DRAWER_SIZE = '800px'
let drawer: Drawer | undefined = $state(undefined)
let editor: EditorHandle | undefined = $state(undefined)
let activeDrawer: ActiveDrawerState | undefined = $state(undefined)
let nextActiveDrawerId = 0
let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state(undefined)
let variableEditor: VariableEditor | undefined = $state(undefined)
const drawerConfigs: Record<DrawerKey, DrawerConfig> = {
schedule: {
label: 'schedule',
load: () => import('$lib/components/triggers/schedules/ScheduleEditorInner.svelte')
},
http: {
label: 'HTTP trigger',
load: () => import('$lib/components/triggers/http/RouteEditorInner.svelte')
},
websocket: {
label: 'WebSocket trigger',
load: () => import('$lib/components/triggers/websocket/WebsocketTriggerEditorInner.svelte')
},
postgres: {
label: 'Postgres trigger',
load: () => import('$lib/components/triggers/postgres/PostgresTriggerEditorInner.svelte')
},
kafka: {
label: 'Kafka trigger',
load: () => import('$lib/components/triggers/kafka/KafkaTriggerEditorInner.svelte')
},
nats: {
label: 'NATS trigger',
load: () => import('$lib/components/triggers/nats/NatsTriggerEditorInner.svelte')
},
mqtt: {
label: 'MQTT trigger',
load: () => import('$lib/components/triggers/mqtt/MqttTriggerEditorInner.svelte')
},
amqp: {
label: 'AMQP trigger',
load: () => import('$lib/components/triggers/amqp/AmqpTriggerEditorInner.svelte')
},
sqs: {
label: 'SQS trigger',
load: () => import('$lib/components/triggers/sqs/SqsTriggerEditorInner.svelte')
},
gcp: {
label: 'GCP Pub/Sub trigger',
load: () => import('$lib/components/triggers/gcp/GcpTriggerEditorInner.svelte')
},
azure: {
label: 'Azure Event Grid trigger',
load: () => import('$lib/components/triggers/azure/AzureTriggerEditorInner.svelte')
},
email: {
label: 'Email trigger',
load: () => import('$lib/components/triggers/email/EmailTriggerEditorInner.svelte')
}
}
const drawerTitle = $derived.by(() => {
const current = activeDrawer
return current ? `Edit ${drawerConfigs[current.key].label} ${current.path}` : 'Edit trigger'
})
async function getEditor(request: ActiveDrawerState): Promise<EditorHandle | undefined> {
await request.promise
await tick()
await nextAnimationFrame()
if (activeDrawer?.id !== request.id) {
return undefined
}
if (!editor) {
await tick()
await nextAnimationFrame()
}
if (!editor) {
throw new Error(`${drawerConfigs[request.key].label} drawer is not ready`)
}
return editor
}
export async function openCreatedResource(action: ToolDisplayAction) {
if (action.type !== 'open_created_resource') {
return
}
if (action.resource === 'resource') {
await resourceEditorDrawer?.initEdit(action.path)
return
}
if (action.resource === 'variable') {
await variableEditor?.editVariable(action.path)
return
}
const key = action.resource === 'schedule' ? 'schedule' : action.triggerKind
if (!key) {
throw new Error('Missing trigger kind')
}
if (activeDrawer?.key === key && activeDrawer.path === action.path && drawer?.isOpen()) {
activeDrawer = undefined
editor = undefined
drawer.closeDrawer()
return
}
const config = drawerConfigs[key]
const promise = activeDrawer?.key === key ? activeDrawer.promise : config.load()
if (activeDrawer?.key !== key) {
editor = undefined
}
const request: ActiveDrawerState = {
id: nextActiveDrawerId++,
key,
path: action.path,
promise
}
activeDrawer = request
drawer?.openDrawer()
const currentEditor = await getEditor(request)
if (!currentEditor) {
return
}
await currentEditor.openEdit(action.path, action.targetKind === 'flow')
}
function onInnerUpdate() {
drawer?.closeDrawer()
}
</script>
<Drawer bind:this={drawer} size={DRAWER_SIZE}>
<DrawerContent title={drawerTitle} on:close={() => drawer?.closeDrawer()}>
{#if activeDrawer}
{#key activeDrawer.key}
{#await activeDrawer.promise}
<div class="p-4 flex justify-center">
<Loader2 class="animate-spin" />
</div>
{:then Module}
{@const Editor = Module.default as EditorComponent}
<Editor bind:this={editor} useDrawer={false} onUpdate={onInnerUpdate} />
{/await}
{/key}
{/if}
</DrawerContent>
</Drawer>
<ResourceEditorDrawer bind:this={resourceEditorDrawer} />
<VariableEditor bind:this={variableEditor} />
@@ -60,7 +60,7 @@ import { logFeatureUsage, logHubScriptPick } from '$lib/utils/featureUsage'
import { forLater } from '$lib/forLater'
import { scriptLangToEditorLang } from '$lib/scripts'
import { getCurrentModel } from '$lib/aiStore'
import { type editor as meditor } from 'monaco-editor'
import type { editor as meditor } from 'monaco-editor'
import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions'
// Prettify function for code arguments - extracts and formats code from JSON
@@ -1,5 +1,5 @@
import { createLongHash } from '$lib/editorLangUtils'
import { type editor as meditor } from 'monaco-editor'
import type { editor as meditor } from 'monaco-editor'
export type VisualChange =
| {
@@ -3,7 +3,7 @@
import { Alert, Badge, ButtonType, Skeleton } from '$lib/components/common'
import { capitalize } from '$lib/utils'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import { APP_TO_ICON_COMPONENT } from '$lib/components/icons'
import { appIconMap } from '$lib/components/icons/appIcon.svelte'
import { listHubIntegrationsShared } from '$lib/components/displayNameLoaders'
import ListFilters from '$lib/components/home/ListFilters.svelte'
import { ScriptService, type HubScriptKind } from '$lib/gen'
@@ -205,8 +205,8 @@
>
<div class="flex items-center gap-4">
<div class="flex justify-center items-center">
{#if item['app'] in APP_TO_ICON_COMPONENT}
{@const SvelteComponent = APP_TO_ICON_COMPONENT[item['app']]}
{#if appIconMap()?.[item['app']]}
{@const SvelteComponent = appIconMap()![item['app']]}
<SvelteComponent height={18} width={18} />
{/if}
</div>
@@ -36,7 +36,7 @@
import { createEventDispatcher, getContext, untrack } from 'svelte'
import { Skeleton } from '$lib/components/common'
import { classNames, createCache } from '$lib/utils'
import { APP_TO_ICON_COMPONENT } from '$lib/components/icons'
import { appIconMap } from '$lib/components/icons/appIcon.svelte'
import { listHubIntegrationsShared } from '$lib/components/displayNameLoaders'
import { ScriptService, type HubScriptKind } from '$lib/gen'
import { Circle, ExternalLink } from 'lucide-svelte'
@@ -253,8 +253,8 @@
onClick={() => handlePickScript(item)}
>
<div class={classNames('flex justify-center items-center')}>
{#if item['app'] in APP_TO_ICON_COMPONENT}
{@const SvelteComponent = APP_TO_ICON_COMPONENT[item['app']]}
{#if appIconMap()?.[item['app']]}
{@const SvelteComponent = appIconMap()![item['app']]}
<SvelteComponent height={13} width={13} />
{:else}
<div class="text-gray-400 flex flex-row items-center justify-center">
@@ -52,7 +52,6 @@
import TriggersNode from './renderers/nodes/TriggersNode.svelte'
import { Alert, Drawer } from '../common'
import Button from '../common/button/Button.svelte'
import FlowYamlEditor from '../flows/header/FlowYamlEditor.svelte'
import BranchOneEndNode from './renderers/nodes/branchOneEndNode.svelte'
import type { TriggerContext } from '../triggers'
import SubflowBound from './renderers/nodes/SubflowBound.svelte'
@@ -1296,7 +1295,10 @@
</script>
{#if insertable}
<FlowYamlEditor bind:drawer={yamlEditorDrawer} />
<!-- Dynamic: the YAML editor is monaco, which read-only graphs (run previews) never need. -->
{#await import('../flows/header/FlowYamlEditor.svelte') then FlowYamlEditor}
<FlowYamlEditor.default bind:drawer={yamlEditorDrawer} />
{/await}
{/if}
{#if canUseDiffDrawer}
<DiffDrawer bind:this={diffDrawer} />
@@ -41,7 +41,6 @@
import NoItemFound from './NoItemFound.svelte'
import WorkspaceEmptyState from './WorkspaceEmptyState.svelte'
import HubProjectPickerModal from './HubProjectPickerModal.svelte'
import ImportProjectModal from './ImportProjectModal.svelte'
import type { HubProjectPick } from '$lib/hubProject'
import ListFilters from './ListFilters.svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
@@ -1009,6 +1008,11 @@
// the create menu's Import section open the same dialog, and mounting one per entry point
// would put two of them on the page at once while the workspace is still empty.
let hubPick = $state<HubProjectPick | undefined>(undefined)
// Sticky, so the import modal mounts on the first pick and then stays for its close animation.
let hubImportRequested = $state(false)
$effect(() => {
if (hubPick) hubImportRequested = true
})
let hubPickerOpen = $state(false)
/**
@@ -2077,7 +2081,13 @@
hubPick = project
}}
/>
<ImportProjectModal pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} />
<!-- Dynamic, on the first pick: the import flow statically reaches ~400 modules (resource
setup, SQL editor). -->
{#if hubImportRequested}
{#await import('./ImportProjectModal.svelte') then ImportProjectModal}
<ImportProjectModal.default pick={hubPick} onClose={() => (hubPick = undefined)} {onImported} />
{/await}
{/if}
<style>
/* Rows arriving after an import, one after another. The animation is declared on the
@@ -2,7 +2,7 @@
import { Folder, User } from 'lucide-svelte'
import { Badge } from '../common'
import type { BadgeColor, BadgeIconProps } from '../common/badge/model'
import { appIconComponent } from '../icons'
import { appIconComponent } from '../icons/appIcon.svelte'
import { integrationDisplayName } from '../resourceTypeDisplay'
import { untrack } from 'svelte'
import { useHostedPage } from '../hostedPage'
@@ -115,7 +115,9 @@
<Icon class="mr-0.5" size={12} />
{:else if resourceType}
{@const SvelteComponent = appIconComponent(filter)}
<SvelteComponent height="14px" width="14px" />
{#if SvelteComponent}
<SvelteComponent height="14px" width="14px" />
{/if}
{:else if filter.startsWith('u/')}
<User class="mr-0.5" size={14} />
{:else if filter.startsWith('f/')}
@@ -1,6 +1,6 @@
<script lang="ts">
import { Folder, User, Circle } from 'lucide-svelte'
import { appIconComponent } from '../icons'
import { appIconComponent } from '../icons/appIcon.svelte'
import { integrationDisplayName } from '../resourceTypeDisplay'
import { createEventDispatcher } from 'svelte'
import { Button } from '../common'
@@ -0,0 +1,32 @@
import type { Component } from 'svelte'
// The ~300 icon modules load on first use rather than up front: statically reached from
// the app shell, they gate every page load in dev, and started at module evaluation they
// compete with the page's own chunks and delay Home's first content. Everything here returns
// `undefined` until they have loaded and then re-runs reactive readers — call from markup or
// a `$derived`, not once into a plain variable.
let icons = $state.raw<typeof import('./index')>()
let loading: Promise<unknown> | undefined
function load() {
if (!icons)
loading ??= import('./index').then(
(m) => (icons = m),
// Cleared so the next read retries rather than keeping the tab icon-less; rethrown
// so the root layout reports a chunk that failed to load.
(e) => {
loading = undefined
throw e
}
)
return icons
}
export function appIconComponent(name: string | undefined): Component | undefined {
if (!name) return undefined
return load()?.appIconComponent(name)
}
export function appIconMap(): Record<string, Component> | undefined {
return load()?.APP_TO_ICON_COMPONENT
}
@@ -32,7 +32,6 @@
import DraftBadge from '$lib/components/DraftBadge.svelte'
import InheritedLabels from '$lib/components/InheritedLabels.svelte'
import ShareModal from '$lib/components/ShareModal.svelte'
import SimpleEditor from '$lib/components/SimpleEditor.svelte'
import SupabaseConnect from '$lib/components/SupabaseConnect.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
@@ -76,7 +75,6 @@
} from 'lucide-svelte'
import { onMount, untrack } from 'svelte'
import autosize from '$lib/autosize'
import EditableSchemaWrapper from '$lib/components/schema/EditableSchemaWrapper.svelte'
import ResourceEditorDrawer from '$lib/components/ResourceEditorDrawer.svelte'
import {
agentEditorTarget,
@@ -820,12 +818,14 @@
<Drawer bind:this={inferrer} size="800px">
<DrawerContent title="Infer type from JSON" on:close={() => inferrer?.toggleDrawer?.()}>
<SimpleEditor
bind:code={inferrerJson}
lang="json"
class="h-full"
fixedOverflowWidgets={false}
/>
{#await import('$lib/components/SimpleEditor.svelte') then SimpleEditor}
<SimpleEditor.default
bind:code={inferrerJson}
lang="json"
class="h-full"
fixedOverflowWidgets={false}
/>
{/await}
{#snippet actions()}
<Button unifiedSize="sm" on:click={inferJson}>Infer</Button>
{/snippet}
@@ -920,7 +920,10 @@
{:else}
<div class="mb-1 font-semibold text-emphasis text-xs">Schema</div>
<div class="flex flex-col gap-2">
<EditableSchemaWrapper bind:schema={editResourceType.schema} noPreview />
<!-- Dynamic, like the one below: the schema editor reaches monaco. -->
{#await import('$lib/components/schema/EditableSchemaWrapper.svelte') then EditableSchemaWrapper}
<EditableSchemaWrapper.default bind:schema={editResourceType.schema} noPreview />
{/await}
</div>
{/if}
</div>
@@ -1008,12 +1011,14 @@
</div>
<div class="flex flex-col gap-2">
<EditableSchemaWrapper
bind:schema={newResourceType.schema}
bind:formatExtension={newResourceType.formatExtension}
bind:isFileset={newResourceType.isFileset}
fullHeight
/>
{#await import('$lib/components/schema/EditableSchemaWrapper.svelte') then EditableSchemaWrapper}
<EditableSchemaWrapper.default
bind:schema={newResourceType.schema}
bind:formatExtension={newResourceType.formatExtension}
bind:isFileset={newResourceType.isFileset}
fullHeight
/>
{/await}
</div>
</div>
</div>
@@ -9,7 +9,6 @@
import QuickMenuItem from './QuickMenuItem.svelte'
import { goto } from '$app/navigation'
import { displayDateOnly } from '$lib/utils'
import JobPreview from '../runs/JobRunsPreview.svelte'
let debounceTimeout: any = undefined
const debouncePeriod: number = 1000
@@ -210,7 +209,11 @@
Select a result to preview
{:else}
<div class="h-[95%] overflow-y-scroll">
<JobPreview id={selectedItem?.document?.id[0]} workspace={selectedWorkspace} />
<!-- Dynamic: the job preview reaches the flow graph and monaco, and this search is
part of the global search modal. -->
{#await import('../runs/JobRunsPreview.svelte') then JobPreview}
<JobPreview.default id={selectedItem?.document?.id[0]} workspace={selectedWorkspace} />
{/await}
</div>
{/if}
<div class="flex flex-row pt-3 pl-4 items-center text-xs text-secondary">
@@ -246,8 +249,9 @@
<div class="text-sm">
Note that new runs might take a while to become searchable (by default ~5min).
{#if indexMetadata?.max_index_time_window_secs}
Search only covers the last {Math.round(indexMetadata.max_index_time_window_secs / 86400)} day(s),
configurable in instance settings under Indexer.
Search only covers the last {Math.round(
indexMetadata.max_index_time_window_secs / 86400
)} day(s), configurable in instance settings under Indexer.
{/if}
</div>
{#if !$enterpriseLicense}
@@ -6,7 +6,9 @@
import { Button } from '$lib/components/common'
import Modal from '$lib/components/common/modal/Modal.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import { GithubIcon, GitlabIcon, GoogleIcon } from '$lib/components/icons'
import GithubIcon from '$lib/components/icons/GithubIcon.svelte'
import GitlabIcon from '$lib/components/icons/GitlabIcon.svelte'
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
import { OauthService } from '$lib/gen'
// An account created through a pre-approved invite has no credentials yet
@@ -10,7 +10,7 @@
import { page } from '$app/stores'
import { slide } from 'svelte/transition'
import { buildWsUrl } from '$lib/wsUrl'
import { signMultiplayerRequest } from '$lib/components/debug'
import { signMultiplayerRequest } from '$lib/components/debug/debugUtils'
let awareness: Awareness | undefined = $state(undefined)
let wsProvider: WebsocketProvider | undefined = undefined
@@ -3,7 +3,7 @@
// lucide glyphs, where a coloured brand mark reads as a different kind of thing.
import { triggerIconMapMono as triggerIconMap, type TriggerType } from './utils'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { SchedulePollIcon } from '$lib/components/icons'
import SchedulePollIcon from '$lib/components/icons/SchedulePollIcon.svelte'
import type { Placement } from '@floating-ui/core'
import { isCloudHosted } from '$lib/cloud'
import { CloudOff } from 'lucide-svelte'
@@ -16,7 +16,9 @@
import { isObject, sendUserToast } from '$lib/utils'
import SchemaPickerRow from '$lib/components/schema/SchemaPickerRow.svelte'
import type { Capture } from '$lib/gen'
import { AwsIcon, MqttIcon, AmqpIcon } from '../icons'
import AwsIcon from '../icons/AwsIcon.svelte'
import MqttIcon from '../icons/MqttIcon.svelte'
import AmqpIcon from '../icons/AmqpIcon.svelte'
import GoogleCloudIcon from '../icons/GoogleCloudIcon.svelte'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -1,12 +1,18 @@
<script lang="ts">
import { tickPainted } from '$lib/utils/paint'
import ScheduleEditorInner from './ScheduleEditorInner.svelte'
import type ScheduleEditorInner from './ScheduleEditorInner.svelte'
let { onUpdate }: { onUpdate?: (path?: string) => void } = $props()
// Dynamic: every script and flow row embeds this wrapper, and the editor statically
// reaches ~280 modules (script picker, flow graph, ...).
let inner: Promise<typeof import('./ScheduleEditorInner.svelte')> | undefined
const loadInner = () => (inner ??= import('./ScheduleEditorInner.svelte'))
let open = $state(false)
export async function openEdit(ePath: string, isFlow: boolean, fixedScriptPath?: string) {
open = true
await loadInner()
await tickPainted()
drawer?.openEdit(ePath, isFlow, undefined, fixedScriptPath)
}
@@ -18,6 +24,7 @@
fixedScriptPath?: string
) {
open = true
await loadInner()
await tickPainted()
drawer?.openNew(is_flow, initial_script_path, undefined, schedule_path, fixedScriptPath)
}
@@ -26,5 +33,7 @@
</script>
{#if open}
<ScheduleEditorInner {onUpdate} bind:this={drawer} />
{#await loadInner() then Inner}
<Inner.default {onUpdate} bind:this={drawer} />
{/await}
{/if}
@@ -5,7 +5,8 @@
import Skeleton from '$lib/components/common/skeleton/Skeleton.svelte'
import SettingsPageHeader from '$lib/components/settings/SettingsPageHeader.svelte'
import { Check, X, ExternalLink, Cog, Plug } from 'lucide-svelte'
import { NextcloudIcon, GithubIcon } from '$lib/components/icons'
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
import GithubIcon from '$lib/components/icons/GithubIcon.svelte'
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
import { WorkspaceIntegrationService, type NativeServiceName } from '$lib/gen'
import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte'
+20 -4
View File
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./gen', () => ({ HubPublishService: {}, SettingService: {} }))
vi.mock('./components/icons', () => ({ appIconComponent: () => undefined }))
vi.mock('./components/icons/appIcon.svelte', () => ({ appIconComponent: () => undefined }))
import { hubProjectDescription, pinHubProjects } from './hubProject'
@@ -52,11 +52,27 @@ describe('hubProjectDescription', () => {
describe('pinHubProjects', () => {
const pick = (slug: string, apps: string[], stars: number) =>
({ id: slug, slug, name: slug, summary: '', description: '', author: '', apps, iconApps: apps, stars }) as any
const all = [pick('a', ['slack'], 9), pick('b', ['hubspot'], 5), pick('c', ['hubspot', 'slack'], 2)]
({
id: slug,
slug,
name: slug,
summary: '',
description: '',
author: '',
apps,
iconApps: apps,
stars
}) as any
const all = [
pick('a', ['slack'], 9),
pick('b', ['hubspot'], 5),
pick('c', ['hubspot', 'slack'], 2)
]
it('hoists the named slugs in the invite order and skips ones the hub no longer lists', () => {
expect(pinHubProjects(all, { hub_projects: ['c', 'gone', 'a'] }).map((p) => [p.slug, p.pinned])).toEqual([
expect(
pinHubProjects(all, { hub_projects: ['c', 'gone', 'a'] }).map((p) => [p.slug, p.pinned])
).toEqual([
['c', true],
['a', true],
['b', false]
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Component } from 'svelte'
import { appIconComponent } from '$lib/components/icons'
import { appIconComponent } from '$lib/components/icons/appIcon.svelte'
import { HubPublishService, SettingService } from '$lib/gen'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import type { ImportProjectSummary } from '$lib/components/ImportProjectCard.svelte'
@@ -1,5 +1,6 @@
<script lang="ts">
import { BROWSER } from 'esm-env'
import { isMac } from '$lib/utils'
import {
AppService,
@@ -54,8 +55,8 @@
import { afterNavigate, beforeNavigate } from '$app/navigation'
import { goto } from '$lib/navigation'
import { registerToolDisplayActionHandler } from '$lib/components/copilot/chat/createdResourceActions.svelte'
import UserSettings from '$lib/components/UserSettings.svelte'
import SuperadminSettings from '$lib/components/SuperadminSettings.svelte'
import type UserSettings from '$lib/components/UserSettings.svelte'
import type SuperadminSettings from '$lib/components/SuperadminSettings.svelte'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import { page } from '$app/state'
import FavoriteMenu, {
@@ -71,7 +72,7 @@
import { deepEqual } from 'fast-equals'
import { twMerge } from 'tailwind-merge'
import OperatorMenu from '$lib/components/sidebar/OperatorMenu.svelte'
import GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import type GlobalSearchModal from '$lib/components/search/GlobalSearchModal.svelte'
import MenuButton from '$lib/components/sidebar/MenuButton.svelte'
import MenuLink from '$lib/components/sidebar/MenuLink.svelte'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
@@ -102,7 +103,6 @@
import { currentWorkspaceRootId } from '$lib/components/sessions/sessionScope.svelte'
import WorkspaceScopeHeader from '$lib/components/sidebar/WorkspaceScopeHeader.svelte'
import { DEFAULT_HUB_BASE_URL } from '$lib/hub'
import DBManagerDrawer from '$lib/components/DBManagerDrawer.svelte'
import S3FilePicker from '$lib/components/S3FilePicker.svelte'
import { useIsDarkMode } from '$lib/components/DarkModeObserver.svelte'
import { useDbManagerUriState } from '$lib/components/dbManagerDrawerModel.svelte'
@@ -686,11 +686,53 @@
}
}
function openSearchModal(
text?: string,
stack?: import('$lib/components/common/overlayHost.svelte').OverlayStack
): void {
globalSearchModal?.openSearchWithPrefilledText(text, stack)
// Sticky: once opened, a drawer stays mounted, so reopening it is instant.
let userSettingsRequested = $state(false)
let superadminSettingsRequested = $state(false)
$effect(() => {
if (page.url.hash.startsWith(USER_SETTINGS_HASH)) userSettingsRequested = true
if (page.url.hash === SUPERADMIN_SETTINGS_HASH) superadminSettingsRequested = true
})
let dbManagerRequested = $state(false)
$effect(() => {
if (globalDbManagerDrawer.val?.open) dbManagerRequested = true
})
type OverlayStack = import('$lib/components/common/overlayHost.svelte').OverlayStack
// The modal is loaded on the first request, which is held here and replayed once it is
// bound. Only where the modal mounts (the markup's `{:else if $userStore}`): elsewhere
// the request would replay much later.
let searchModalRequested = $state(false)
let pendingSearchOpen: { text?: string; stack?: OverlayStack } | undefined = $state()
let searchModalMounts = $derived(page.status != 404 && !!$userStore)
function openSearchModal(text?: string, stack?: OverlayStack): void {
if (globalSearchModal) globalSearchModal.openSearchWithPrefilledText(text, stack)
else if (searchModalMounts) {
pendingSearchOpen = { text, stack }
searchModalRequested = true
}
}
$effect(() => {
if (globalSearchModal && pendingSearchOpen) {
const { text, stack } = pendingSearchOpen
pendingSearchOpen = undefined
untrack(() => globalSearchModal?.openSearchWithPrefilledText(text, stack))
}
})
// Ctrl/Cmd+K is what first loads the modal; once bound, the modal's own listener takes over.
function onSearchShortcutBeforeLoad(e: KeyboardEvent) {
if (
!globalSearchModal &&
searchModalMounts &&
(isMac() ? e.metaKey : e.ctrlKey) &&
e.key === 'k'
) {
e.preventDefault()
openSearchModal()
}
}
setContext('openSearchWithPrefilledText', openSearchModal)
@@ -921,7 +963,7 @@
})
</script>
<svelte:window bind:innerWidth />
<svelte:window bind:innerWidth onkeydown={onSearchShortcutBeforeLoad} />
<!-- Home + Runs lifted to the top of the workspace nav, sitting with Favorites
and Search as the primary quick-access cluster. Excluded from SidebarContent
@@ -990,7 +1032,14 @@
</div>
{/snippet}
<UserSettings bind:this={userSettings} showMcpMode={true} />
<!-- The drawers and modals below are dynamic imports mounted on first open: this layout
wraps every workspace page, so a static import gates first paint, and a load on mount
competes with the page for bandwidth (several of them reach monaco). -->
{#if userSettingsRequested}
{#await import('$lib/components/UserSettings.svelte') then UserSettings}
<UserSettings.default bind:this={userSettings} showMcpMode={true} />
{/await}
{/if}
{#if accountSetup.pending}
<FinishAccountSetup
bind:open={accountSetup.open}
@@ -1002,9 +1051,15 @@
{#if page.status == 404}
<CenteredModal title="Page not found, redirecting you to login" loading={true}></CenteredModal>
{:else if $userStore}
<GlobalSearchModal bind:this={globalSearchModal} />
{#if $superadmin}
<SuperadminSettings bind:this={superadminSettings} />
{#if searchModalRequested}
{#await import('$lib/components/search/GlobalSearchModal.svelte') then GlobalSearchModal}
<GlobalSearchModal.default bind:this={globalSearchModal} />
{/await}
{/if}
{#if $superadmin && superadminSettingsRequested}
{#await import('$lib/components/SuperadminSettings.svelte') then SuperadminSettings}
<SuperadminSettings.default bind:this={superadminSettings} />
{/await}
{/if}
{#if mountModal}
<CriticalAlertModal bind:muteSettings bind:numUnacknowledgedCriticalAlerts />
@@ -1502,8 +1557,10 @@
<CenteredModal title="Loading user..." loading={true}></CenteredModal>
{/if}
{#if $workspaceStore && globalDbManagerDrawer.val}
<DBManagerDrawer uriState={globalDbManagerDrawer.val} />
{#if $workspaceStore && globalDbManagerDrawer.val && dbManagerRequested}
{#await import('$lib/components/DBManagerDrawer.svelte') then DBManagerDrawer}
<DBManagerDrawer.default uriState={globalDbManagerDrawer.val} />
{/await}
{/if}
{#if $workspaceStore}
@@ -21,7 +21,9 @@
import PageHeader from '$lib/components/PageHeader.svelte'
import { Button, Alert, EmptyState, Skeleton } from '$lib/components/common'
import { LoaderCircle, Plus, Webhook } from 'lucide-svelte'
import { GithubIcon, GoogleIcon, NextcloudIcon } from '$lib/components/icons'
import GithubIcon from '$lib/components/icons/GithubIcon.svelte'
import GoogleIcon from '$lib/components/icons/GoogleIcon.svelte'
import NextcloudIcon from '$lib/components/icons/NextcloudIcon.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import NoItemFound from '$lib/components/home/NoItemFound.svelte'
import { page } from '$app/state'
@@ -14,7 +14,7 @@
import { emptyString, parseQueryParams } from '$lib/utils'
import { getUserExt } from '$lib/user'
import LoginPageHeader from '$lib/components/LoginPageHeader.svelte'
import { WindmillIcon } from '$lib/components/icons'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import { clearStores } from '$lib/storeUtils'
import { setLicense } from '$lib/enterpriseUtils'
import Login from '$lib/components/Login.svelte'
@@ -20,7 +20,7 @@
import Toggle from '$lib/components/Toggle.svelte'
import UserSettings from '$lib/components/UserSettings.svelte'
import SuperadminSettings from '$lib/components/SuperadminSettings.svelte'
import { WindmillIcon } from '$lib/components/icons'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { USER_SETTINGS_HASH } from '$lib/components/sidebar/settings'
import { switchWorkspace } from '$lib/storeUtils'
@@ -3,7 +3,7 @@
import { page } from '$app/state'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { Alert } from '$lib/components/common'
import { WindmillIcon } from '$lib/components/icons'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import { WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
+27 -1
View File
@@ -29,6 +29,17 @@
let { children }: Props = $props()
// A lazy chunk that failed to load: Chromium, Firefox, Safari, then Vite's CSS preload.
const chunkLoadErrors = [
'Failed to fetch dynamically imported',
'error loading dynamically imported module',
'Importing a module script failed',
'Unable to preload CSS'
]
// One failed component rejects once per chunk and stylesheet it needed: one toast per
// page, keyed on the path since this layout outlives client-side navigation.
let chunkLoadToastPath: string | undefined = undefined
const monacoEditorUnhandledErrors = [
'Model not found',
'Connection is disposed.',
@@ -200,6 +211,22 @@
if (event.reason?.message) {
const { message, body, status } = event.reason
// A chunk gone from the server, typically a tab left open across an upgrade: a
// component loaded on demand would otherwise silently never appear.
if (chunkLoadErrors.some((m) => message.startsWith(m))) {
console.warn(message)
// In dev nothing is ever stale: this is a compile error, already on Vite's overlay.
if (!import.meta.env.PROD || chunkLoadToastPath === location.pathname) return
chunkLoadToastPath = location.pathname
sendUserToast(
'Part of the page failed to load, Windmill may have been updated',
true,
[{ label: 'Reload', callback: () => location.reload() }],
undefined,
15000
)
return
}
if (message === 'Missing service editorService') {
console.error('Reloading the page to fix a Monaco Editor bug')
location.reload()
@@ -208,7 +235,6 @@
// Unhandled errors from Monaco Editor don't logout the user
if (
monacoEditorUnhandledErrors.includes(message) ||
message.startsWith('Failed to fetch dynamically imported') ||
message.startsWith('Unable to figure out browser width and height') ||
message.startsWith('Unable to read file') ||
message.startsWith('Could not find source file')
@@ -6,7 +6,7 @@
// always fails with the credentials error, and the provider buttons don't navigate.
import Login, { type LoginPreview } from '$lib/components/Login.svelte'
import LoginHeading from '$lib/components/LoginHeading.svelte'
import { WindmillIcon } from '$lib/components/icons'
import WindmillIcon from '$lib/components/icons/WindmillIcon.svelte'
import DarkModeToggle from '$lib/components/sidebar/DarkModeToggle.svelte'
import Select from '$lib/components/select/Select.svelte'
import { goto } from '$lib/navigation'
+73 -24
View File
@@ -77,6 +77,36 @@ function assertAcyclicChunks() {
}
}
/**
* Fail the build if a chunk statically reachable from one containing `entry` contains a module
* whose id includes one of `forbidden`. Only static imports count: a dynamic import is the
* fix, not the problem.
*/
function assertStaticClosureExcludes(ctx, bundle, entry, forbidden, hint) {
const chunks = Object.entries(bundle).filter(([, c]) => c.type === 'chunk')
const idsOf = (c) => c.moduleIds ?? Object.keys(c.modules ?? {})
const starts = chunks
.filter(([, c]) => idsOf(c).some((id) => id.includes(entry)))
.map(([file]) => file)
if (!starts.length) ctx.error(`No chunk contains ${entry}; update ${hint}`)
const seen = new Set(starts)
const queue = [...starts]
while (queue.length) {
const chunk = bundle[queue.shift()]
if (!chunk) continue
const hit = idsOf(chunk).find((id) => forbidden.some((f) => id.includes(f)))
if (hit) ctx.error(`${entry} statically loads ${hit}; import it lazily (see ${hint})`)
for (const dep of chunk.imports ?? []) {
if (seen.has(dep)) continue
seen.add(dep)
queue.push(dep)
}
}
}
const isClientBuild = (bundle) =>
Object.keys(bundle).some((file) => file.startsWith('_app/immutable/'))
/**
* Fail the build if a public app URL statically loads the low-code runtime or monaco.
*
@@ -94,30 +124,36 @@ function assertLeanPublicAppRoutes() {
return {
name: 'wm-assert-lean-public-app-routes',
generateBundle(_options, bundle) {
const chunks = Object.entries(bundle).filter(([, c]) => c.type === 'chunk')
if (!chunks.some(([file]) => file.startsWith('_app/immutable/'))) return
const idsOf = (c) => c.moduleIds ?? Object.keys(c.modules ?? {})
if (!isClientBuild(bundle)) return
for (const route of routes) {
const starts = chunks
.filter(([, c]) => idsOf(c).some((id) => id.includes(route)))
.map(([file]) => file)
if (!starts.length)
this.error(`No chunk contains ${route}; update assertLeanPublicAppRoutes`)
const seen = new Set(starts)
const queue = [...starts]
while (queue.length) {
const chunk = bundle[queue.shift()]
if (!chunk) continue
const hit = idsOf(chunk).find((id) => forbidden.some((f) => id.includes(f)))
if (hit) {
this.error(`${route} statically loads ${hit}; import it lazily (see loadAppPreview.ts)`)
}
for (const dep of chunk.imports ?? []) {
if (seen.has(dep)) continue
seen.add(dep)
queue.push(dep)
}
}
assertStaticClosureExcludes(this, bundle, route, forbidden, 'loadAppPreview.ts')
}
}
}
}
/**
* Fail the build if the app shell or the home page statically loads monaco.
*
* The layouts below wrap every page, so what they import statically is downloaded before
* any page renders, and the home page is where most sessions land; monaco is several MB.
* Editors, drawers and modals that reach it are dynamic imports mounted on first use (see
* the `(logged)` layout).
*/
function assertMonacoFreeAppShell() {
const entries = [
'/src/routes/+layout.svelte',
'/src/routes/(root)/+layout.svelte',
'/src/routes/(root)/(logged)/+layout.svelte',
'/src/routes/(root)/(logged)/+page.svelte'
]
const forbidden = ['/node_modules/monaco-editor/', '/node_modules/@codingame/monaco-vscode-']
return {
name: 'wm-assert-monaco-free-app-shell',
generateBundle(_options, bundle) {
if (!isClientBuild(bundle)) return
for (const entry of entries) {
assertStaticClosureExcludes(this, bundle, entry, forbidden, 'the (logged) layout')
}
}
}
@@ -208,6 +244,18 @@ const config = {
'public.windmill.xyz'
],
port: parseInt(process.env.FRONTEND_PORT) || 3000,
// Transform the app shell's module graph at startup rather than on the first page
// load, which otherwise sits on the loading screen for ~15s (Tailwind's full content
// scan for app.css alone takes several seconds).
warmup: {
clientFiles: [
'./src/lib/assets/app.css',
'./src/routes/+layout.svelte',
'./src/routes/(root)/+layout.svelte',
'./src/routes/(root)/(logged)/+layout.svelte',
'./src/routes/(root)/(logged)/+page.svelte'
]
},
cors: { origin: '*' },
// `windmill-chat` (svelte.config.js alias) lives outside the frontend root.
fs: {
@@ -279,7 +327,8 @@ const config = {
...(process.env.HTTPS === 'true' ? [mkcert()] : []),
plugin,
assertAcyclicChunks(),
assertLeanPublicAppRoutes()
assertLeanPublicAppRoutes(),
assertMonacoFreeAppShell()
],
define: { '__pkg__.version': JSON.stringify(version) },
optimizeDeps: {