Files
windmill/frontend/src/lib/components/home/CreateActionsMenu.svelte
T
2026-09-30 11:10:51 +02:00

775 lines
27 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { base } from '$lib/base'
import { goto } from '$lib/navigation'
import { Button } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import DrawerContent from '$lib/components/common/drawer/DrawerContent.svelte'
import Tabs from '$lib/components/common/tabs/Tabs.svelte'
import Tab from '$lib/components/common/tabs/Tab.svelte'
import {
Plus,
Code2,
LayoutDashboard,
ChevronDown,
ChevronRight,
Loader2,
Workflow,
Import,
Store,
PanelLeftClose
} from 'lucide-svelte'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import DbtIcon from '$lib/components/icons/DbtIcon.svelte'
import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons'
import { HOME_SHOW_CREATE_FLOW, HOME_SHOW_CREATE_APP } from '$lib/consts'
import { importFlowStore } from '$lib/components/flows/flowStore.svelte'
import { importScriptStore } from '$lib/components/scripts/scriptStore.svelte'
import { importStore } from '$lib/components/apps/store'
import { conditionalMelt, getLocalSetting, storeLocalSetting } from '$lib/utils'
import { createDropdownMenu, melt } from '@melt-ui/svelte'
import YAML from 'yaml'
import type { Snippet } from 'svelte'
import { logFeatureUsage } from '$lib/utils/featureUsage'
interface Props {
/** Replaces the default `New` button, e.g. with an inline text link. */
trigger?: Snippet
/** The node `trigger` renders: what the menu anchors to and what opens it. */
triggerElement?: HTMLElement
/** Which entry point this menu hangs off, for telemetry. */
source?: 'toolbar' | 'empty_state'
/**
* Opens the hub project picker. The menu only offers the entry; the picker and the
* import dialog belong to the host, which is the one place a single import modal can
* serve both this menu and the empty state's own link.
*/
onImportHubProject?: () => void
}
let { trigger, triggerElement, source = 'toolbar', onImportHubProject }: Props = $props()
type Variant = {
label: string
icon: typeof PythonIcon
onSelect: () => void
badge?: { label: string; class: string }
/** opened in a new tab from a link beside the variant */
docHref?: string
/** shown in the doc panel while the variant is hovered, instead of its option's */
doc?: Doc
}
/** what the doc panel shows */
type Doc = {
label: string
/** a lucide icon or a brand mark; rendered at 26px through both size APIs */
icon: any
accent: string
tagline: string
description: string
bullets: string[]
badge?: { label: string; class: string }
}
/** an importable artifact kind handled by the shared YAML/JSON import drawer */
type ImportKind = 'flow' | 'wac' | 'app-lowcode' | 'app-fullcode'
type Extra = {
label: string
onSelect: () => void
}
type Option = {
key: string
label: string
icon: typeof Code2 | typeof BarsStaggered
/** tailwind accent token used for the icon tile / hover border */
accent: string
tagline: string
description: string
bullets: string[]
onSelect: () => void
/** optional sub-choices shown in place of the single create button */
variants?: Variant[]
/** optional pill shown next to the label; class is the full static tailwind tone */
badge?: { label: string; class: string }
/** secondary actions (import from YAML/JSON, pipeline, …) shown in the detail panel */
extras?: Extra[]
}
// kept static so tailwind doesn't purge the badge tones
const badgeAdvanced = 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
const badgeLegacy = 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
const badgeAlpha = 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300'
const badgeRecommended = 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
const DBT_DOCS = 'https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt'
const allOptions: Option[] = [
{
key: 'script',
label: 'Script',
icon: Code2,
accent: 'blue',
tagline: 'A single standalone script',
description:
'Author a script in Python, TypeScript, Go, Bash, SQL, Rust, PHP and more. Windmill auto-generates an input UI, deploys it instantly and exposes it as an API.',
bullets: [
'20+ languages',
'Auto-generated UI from parameters',
'Instant deploy & versioning'
],
onSelect: () => goto(`${base}/scripts/add`)
},
...(HOME_SHOW_CREATE_FLOW
? ([
{
key: 'flow',
label: 'Flow',
icon: BarsStaggered,
accent: 'teal',
tagline: 'Compose scripts into a workflow',
description:
'Visual builder for chaining scripts together with branches, loops, error handlers, approvals and retries. Each step can be reused from the workspace or the Hub.',
bullets: [
'Drag-and-drop steps',
'Branches, loops & error handling',
'Suspend / approval steps'
],
onSelect: () => goto(`${base}/flows/add`),
extras: [{ label: 'Import flow', onSelect: () => openImport('flow') }]
}
] as Option[])
: []),
...(HOME_SHOW_CREATE_APP
? ([
{
key: 'app-fullcode',
label: 'App (full-code)',
icon: LayoutDashboard,
accent: 'orange',
tagline: 'Build with React or Svelte',
description:
'Full control over the UI with React or Svelte and a powerful AI agent. Best for complex apps that need full flexibility.',
bullets: ['React or Svelte', 'Full flexibility & control', 'AI-assisted authoring'],
onSelect: () => goto(`${base}/apps_raw/add`),
extras: [{ label: 'Import full-code app', onSelect: () => openImport('app-fullcode') }]
}
] as Option[])
: []),
...(HOME_SHOW_CREATE_FLOW
? ([
{
key: 'wac',
label: 'Workflow-as-Code',
icon: Code2,
accent: 'purple',
tagline: 'Express a workflow purely in code',
description:
'Write the whole workflow as a single Python or TypeScript script using the Windmill SDK — parallelism, branching and step orchestration expressed as plain code.',
bullets: [
'Python or TypeScript',
'Full control via the SDK',
'Versioned as a regular script'
],
onSelect: () => goto(`${base}/scripts/add?wac=typescript`),
badge: { label: 'Advanced', class: badgeAdvanced },
variants: [
{
label: 'TypeScript',
icon: TypeScriptIcon,
onSelect: () => goto(`${base}/scripts/add?wac=typescript`)
},
{
label: 'Python',
icon: PythonIcon,
onSelect: () => goto(`${base}/scripts/add?wac=python`)
}
],
extras: [{ label: 'Import Workflow-as-Code', onSelect: () => openImport('wac') }]
},
{
key: 'pipeline',
label: 'Data pipelines',
icon: Workflow,
accent: 'indigo',
tagline: 'Compose data ingestion & transforms',
description:
'Visual editor for data pipelines — chain ingestion, transformation and materialization steps with partitions and incremental processing.',
bullets: [
'Ingest, transform & materialize',
'Partitioned & incremental',
'Asset-aware lineage'
],
onSelect: () => goto(`${base}/pipeline`),
badge: { label: 'Alpha', class: badgeAlpha },
variants: [
{
label: 'Windmill pipelines',
icon: Workflow,
badge: { label: 'Recommended', class: badgeRecommended },
onSelect: () => goto(`${base}/pipeline`)
},
{
label: 'dbt',
icon: DbtIcon,
docHref: DBT_DOCS,
onSelect: () => goto(`${base}/scripts/add?lang=dbt`),
doc: {
label: 'dbt',
icon: DbtIcon,
accent: 'orange',
tagline: 'Run an existing dbt project',
description:
'A pipeline is Windmill’s own abstraction; an existing dbt project stays a dbt project, and runs here unchanged as its own kind of script, with dbt still owning its models, refs and tests. All it needs is a warehouse configured under Settings → dbt. Its models show up in the asset graph as dbt:// nodes, next to whatever pipelines you do build, so you can adopt pipelines later, or never.',
bullets: [
'Your dbt project, unchanged',
'Needs a warehouse under Settings → dbt',
'Models appear as dbt:// assets'
]
}
}
]
}
] as Option[])
: []),
...(HOME_SHOW_CREATE_APP
? ([
{
key: 'app-lowcode',
label: 'App (low-code)',
icon: LayoutDashboard,
accent: 'gray',
tagline: 'Drag-and-drop UI builder',
description:
'Assemble an internal UI from 60+ components wired to your scripts and flows. Best for simple apps or apps that need minimal customization.',
bullets: ['60+ ready-made components', 'No code required', 'Backed by scripts & flows'],
onSelect: () => goto(`${base}/apps/add`),
badge: { label: 'Legacy', class: badgeLegacy },
extras: [{ label: 'Import low-code app', onSelect: () => openImport('app-lowcode') }]
}
] as Option[])
: [])
]
// tailwind needs the full class names statically — map accent token -> classes
const accentClasses: Record<
string,
{ tile: string; iconText: string; activeBg: string; activeBorder: string }
> = {
blue: {
tile: 'bg-blue-100 dark:bg-blue-900/40',
iconText: 'text-blue-600 dark:text-blue-400',
activeBg: 'bg-blue-50 dark:bg-blue-900/20',
activeBorder: 'border-blue-500 dark:border-blue-400'
},
teal: {
tile: 'bg-teal-50 dark:bg-teal-900/20',
iconText: 'text-teal-600 dark:text-teal-400',
activeBg: 'bg-teal-50 dark:bg-teal-900/20',
activeBorder: 'border-teal-500 dark:border-teal-400'
},
purple: {
tile: 'bg-purple-100 dark:bg-purple-900/40',
iconText: 'text-purple-600 dark:text-purple-400',
activeBg: 'bg-purple-50 dark:bg-purple-900/20',
activeBorder: 'border-purple-500 dark:border-purple-400'
},
orange: {
tile: 'bg-orange-100 dark:bg-orange-900/40',
iconText: 'text-orange-600 dark:text-orange-400',
activeBg: 'bg-orange-50 dark:bg-orange-900/20',
activeBorder: 'border-orange-500 dark:border-orange-400'
},
indigo: {
tile: 'bg-indigo-100 dark:bg-indigo-900/40',
iconText: 'text-indigo-600 dark:text-indigo-400',
activeBg: 'bg-indigo-50 dark:bg-indigo-900/20',
activeBorder: 'border-indigo-500 dark:border-indigo-400'
},
gray: {
tile: 'bg-gray-100 dark:bg-gray-700',
iconText: 'text-gray-600 dark:text-gray-300',
activeBg: 'bg-gray-50 dark:bg-gray-800/40',
activeBorder: 'border-gray-400 dark:border-gray-500'
}
}
// the doc panel only shows while an option is hovered or focused, so the menu opens compact
let activeKey: string | undefined = $state(undefined)
// every option's import action, surfaced together under the bottom "Import" submenu.
// The hub project leads and is separated below: the others each paste one artifact the
// user already holds, while this one brings a whole project in from somewhere else.
const importActions: Extra[] = $derived([
...(onImportHubProject
? [{ label: 'Import a hub project', onSelect: onImportHubProject }]
: []),
...allOptions.flatMap((o) => o.extras ?? [])
])
// melt dropdown menu: arrow-key nav, typeahead, focus management and outside/escape
// close all come for free; we only drive the doc panel off the highlighted item.
const {
elements: { trigger: menuTrigger, menu, item },
states: { open },
builders
} = createDropdownMenu({
positioning: { placement: 'bottom-end', gutter: 8, fitViewport: true, strategy: 'fixed' },
loop: true,
forceVisible: true
})
// per-row fan-out submenus: open to the right when there's room. The popover hugs
// the right viewport edge on most screens, so the default flip (right → left) would
// cover the doc panel — fall back below the trigger row instead.
const variantSubmenu = () =>
builders.createSubmenu({
positioning: {
placement: 'right-start',
gutter: 4,
flip: { fallbackPlacements: ['bottom-end', 'bottom-start', 'top-end', 'top-start'] },
fitViewport: true,
overflowPadding: 8
}
})
const {
elements: { subTrigger: wacSubTrigger, subMenu: wacSubMenu },
states: { subOpen: wacSubOpen }
} = variantSubmenu()
const {
elements: { subTrigger: pipelineSubTrigger, subMenu: pipelineSubMenu },
states: { subOpen: pipelineSubOpen }
} = variantSubmenu()
let variantSubOpen = $derived($wacSubOpen || $pipelineSubOpen)
const {
elements: { subTrigger: importSubTrigger, subMenu: importSubMenu },
states: { subOpen: importSubOpen }
} = builders.createSubmenu({
positioning: {
placement: 'right-end',
gutter: 4,
flip: { fallbackPlacements: ['bottom-end', 'bottom-start', 'top-end', 'top-start'] },
fitViewport: true,
overflowPadding: 8
}
})
// When a fan-out submenu falls below its row ('bottom'/'top' data-side), melt aligns
// it to the row's right edge, but free viewport space may remain beside the popover —
// slide it right until it hugs the viewport edge. Melt repositions on scroll/resize
// via style/data-side writes, so re-apply on attribute mutations.
function hugViewportRight(node: HTMLElement) {
let delta = 0
const apply = () => {
const side = node.getAttribute('data-side')
const baseRight = node.getBoundingClientRect().right - delta
const next =
side === 'bottom' || side === 'top'
? Math.max(0, document.documentElement.clientWidth - 8 - baseRight)
: 0
if (Math.abs(next - delta) < 0.5) return
delta = next
node.style.transform = next ? `translateX(${next}px)` : ''
}
const observer = new MutationObserver(apply)
observer.observe(node, { attributeFilter: ['style', 'data-side'] })
// melt only rewrites style when the reposition moves the submenu — a resize that
// changes clientWidth without moving it (scrollbar appearing, zoom) needs a direct hook
window.addEventListener('resize', apply)
apply()
return {
destroy: () => {
observer.disconnect()
window.removeEventListener('resize', apply)
}
}
}
// attach the menu trigger to the design-system <Button>'s DOM node, so it keeps its
// styling — melt element stores are callable on a node, exactly like `use:melt`.
let triggerEl: HTMLButtonElement | HTMLAnchorElement | undefined = $state(undefined)
$effect(() => {
const el = triggerElement ?? triggerEl
if (!el) return
const applied = conditionalMelt(el, menuTrigger as any) as {
destroy?: () => void
}
return applied?.destroy
})
// Which entry point people actually create from: the toolbar button, or the inline
// link in the empty state. Only the open edge counts — melt writes the store on
// close and on every re-render of the menu.
let wasOpen = false
$effect(() => {
const isOpen = $open
if (isOpen && !wasOpen) {
logFeatureUsage('home', 'new_menu_open', { key: source })
}
if (!isOpen) {
activeKey = undefined
activeVariantDoc = undefined
}
wasOpen = isOpen
})
const SHOW_DOC_SETTING = 'home_create_show_doc'
let showDoc = $state(getLocalSetting(SHOW_DOC_SETTING) !== 'false')
function setShowDoc(value: boolean) {
showDoc = value
// only persist the non-default (hidden) state, so a cleared key means "shown"
storeLocalSetting(SHOW_DOC_SETTING, value ? undefined : 'false')
}
// The pointer crosses the gutter outside the menu on its way into a variant submenu,
// so leaving the menu keeps the panel while that submenu is open; it clears once the
// submenu closes with neither pointer nor focus left on the menu.
let menuEl: HTMLDivElement | undefined = $state(undefined)
$effect(() => {
if (variantSubOpen || !menuEl) return
if (!menuEl.matches(':hover') && !menuEl.contains(document.activeElement)) {
activeKey = undefined
}
})
// A hovered variant with its own doc (dbt under Data pipelines) takes over the panel.
let activeVariantDoc: Doc | undefined = $state(undefined)
function highlightOption(key: string | undefined) {
activeKey = key
activeVariantDoc = undefined
}
let active: Doc | undefined = $derived(
activeVariantDoc ?? allOptions.find((o) => o.key === activeKey)
)
let activeAc = $derived(active ? accentClasses[active.accent] : undefined)
// shared YAML/JSON import drawer, reused by every "Import …" extra
let importDrawer: Drawer | undefined = $state(undefined)
let importKind: ImportKind = $state('flow')
let importType: 'yaml' | 'json' = $state('yaml')
let importRaw: string = $state('')
const importTitles: Record<ImportKind, string> = {
flow: 'Import flow',
wac: 'Import Workflow-as-Code',
'app-lowcode': 'Import low-code app',
'app-fullcode': 'Import full-code app'
}
function openImport(kind: ImportKind) {
importKind = kind
importType = 'yaml'
importRaw = ''
open.set(false)
importDrawer?.openDrawer?.()
}
async function runImport() {
const parsed = importType === 'yaml' ? YAML.parse(importRaw) : JSON.parse(importRaw)
if (importKind === 'flow') {
importFlowStore.set(parsed)
await goto(`${base}/flows/add`)
} else if (importKind === 'wac') {
importScriptStore.set(parsed)
await goto(`${base}/scripts/add?import=true`)
} else if (importKind === 'app-fullcode') {
// The raw app editor reads the payload from sessionStorage on mount.
sessionStorage.setItem('rawAppImport', JSON.stringify(parsed))
await goto(`${base}/apps_raw/add`)
} else {
importStore.set(parsed)
await goto(`${base}/apps/add`)
}
importDrawer?.closeDrawer?.()
}
</script>
<!-- `contents` so a custom inline trigger (the empty state's text link) keeps flowing
with the sentence around it instead of becoming a block of its own. -->
<div class={trigger ? 'contents' : ''}>
{#if trigger}
{@render trigger()}
{:else}
<Button
{...$menuTrigger}
id="create-new-button"
aiId="home-create-new"
aiDescription="Create a new script, flow or app"
unifiedSize="md"
variant="accent"
startIcon={{ icon: Plus }}
endIcon={{ icon: ChevronDown }}
bind:element={triggerEl}
>
New
</Button>
{/if}
</div>
{#if $open}
<!-- The positioned element keeps its width while the doc panel toggles: floating-ui
anchors it by its left edge, so a resize would paint one frame at the old left before
the reposition. The visible card grows leftward inside it; the rest ignores the pointer. -->
<div
use:melt={$menu}
data-arrow-loop
class="z-[6000] flex flex-row justify-end pointer-events-none focus:outline-none"
style={showDoc ? 'width: 780px;' : ''}
bind:this={menuEl}
onpointerleave={() => {
if (!variantSubOpen) highlightOption(undefined)
}}
>
<div
class="pointer-events-auto flex flex-row rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl {showDoc &&
active
? 'w-full'
: ''}"
>
{#if showDoc && active && activeAc}
<!-- explanation of the highlighted editor -->
<div class="flex flex-col gap-3 p-5 flex-1 min-w-0">
<div class="flex flex-row items-center gap-3">
<div
class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 {activeAc.tile}"
>
<active.icon size={26} width={26} height={26} class={activeAc.iconText} />
</div>
<div class="min-w-0">
<div class="flex flex-row items-center gap-2">
<h3 class="font-semibold text-primary leading-tight">{active.label}</h3>
{#if active.badge}
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {active
.badge.class}"
>
{active.badge.label}
</span>
{/if}
</div>
<p class="text-xs text-tertiary">{active.tagline}</p>
</div>
</div>
<p class="text-xs text-secondary leading-relaxed">{active.description}</p>
<ul class="flex flex-col gap-1.5 mt-1">
{#each active.bullets as bullet (bullet)}
<li class="flex flex-row items-center gap-2 text-xs text-secondary">
<ChevronRight size={14} class={activeAc.iconText} />
{bullet}
</li>
{/each}
</ul>
<button
class="mt-auto self-start inline-flex items-center gap-1 pt-2 text-[10px] text-tertiary hover:text-secondary transition-colors"
title="Hide descriptions"
tabindex={-1}
onclick={() => setShowDoc(false)}
>
<PanelLeftClose size={12} />
Hide descriptions
</button>
</div>
{/if}
<!-- option list -->
<div class="flex flex-col gap-0.5 p-2 w-[18rem] shrink-0">
{#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])}
<div class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 {ac.tile}">
<option.icon size={14} class={ac.iconText} />
</div>
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
{option.label}
</span>
{#if option.badge}
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {option
.badge.class}"
>
{option.badge.label}
</span>
{/if}
{/snippet}
{#snippet variantRow(
option: Option,
ac: (typeof accentClasses)[string],
rowClass: string,
subTrigger: any,
subMenu: any,
subOpen: boolean
)}
<button
use:melt={subTrigger}
class={rowClass}
onfocusin={() => highlightOption(option.key)}
onpointerenter={() => highlightOption(option.key)}
>
{@render rowBody(option, ac)}
<ChevronRight size={14} class="shrink-0 text-tertiary" />
</button>
{#if subOpen}
<!-- The invisible `before:` margin bridges the gutter to the trigger row: when
the submenu falls below its row, that gutter lies over the next row, and
crossing it highlights that item, which closes the submenu. -->
<div
use:melt={subMenu}
use:hugViewportRight
class="pointer-events-auto z-[6001] flex flex-col gap-0.5 p-1 min-w-52 w-max rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none before:absolute before:-inset-2 before:-z-10 before:content-['']"
>
{#each option.variants ?? [] as variant (variant.label)}
{@const VariantIcon = variant.icon}
<div class="flex flex-row items-center gap-1">
<button
use:melt={$item}
class="flex flex-row flex-1 items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => variant.onSelect()}
onfocusin={() => (activeVariantDoc = variant.doc)}
onpointerenter={() => (activeVariantDoc = variant.doc)}
>
<VariantIcon width={14} height={14} size={14} />
<span class="text-xs font-medium text-primary whitespace-nowrap">
{variant.label}
</span>
{#if variant.badge}
<span
class="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide {variant
.badge.class}"
>
{variant.badge.label}
</span>
{/if}
</button>
{#if variant.docHref}
<a
href={variant.docHref}
target="_blank"
rel="noreferrer"
class="px-2 text-[10px] text-tertiary hover:text-secondary hover:underline whitespace-nowrap"
onpointerenter={() => (activeVariantDoc = variant.doc)}
>
See documentation
</a>
{/if}
</div>
{/each}
</div>
{/if}
{/snippet}
{#each allOptions as option (option.key)}
{@const ac = accentClasses[option.accent]}
{@const rowClass =
'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'}
{#if option.variants && option.key === 'pipeline'}
{@render variantRow(
option,
ac,
rowClass,
$pipelineSubTrigger,
$pipelineSubMenu,
$pipelineSubOpen
)}
{:else if option.variants}
{@render variantRow(option, ac, rowClass, $wacSubTrigger, $wacSubMenu, $wacSubOpen)}
{:else}
<button
use:melt={$item}
class={rowClass}
onfocusin={() => highlightOption(option.key)}
onpointerenter={() => highlightOption(option.key)}
onclick={() => option.onSelect()}
>
{@render rowBody(option, ac)}
</button>
{/if}
{/each}
<!-- bottom import section: one entry whose submenu imports any artifact -->
<div class="mx-1 my-1 border-t border-gray-200 dark:border-gray-700"></div>
<button
use:melt={$importSubTrigger}
class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onfocusin={() => highlightOption(undefined)}
onpointerenter={() => highlightOption(undefined)}
>
<div
class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700"
>
<Import size={14} class="text-gray-600 dark:text-gray-300" />
</div>
<span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap">
Import
</span>
<ChevronRight size={14} class="shrink-0 text-tertiary" />
</button>
{#if $importSubOpen}
<div
use:melt={$importSubMenu}
use:hugViewportRight
class="pointer-events-auto z-[6001] flex flex-col gap-0.5 p-1 w-52 rounded-lg border border-gray-200 dark:border-gray-700 bg-surface shadow-xl focus:outline-none before:absolute before:-inset-2 before:-z-10 before:content-['']"
>
{#each importActions as action, i (action.label)}
<button
use:melt={$item}
class="flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover"
onclick={() => action.onSelect()}
>
{#if onImportHubProject && i === 0}
<Store size={14} class="shrink-0 text-tertiary" />
{:else}
<Import size={14} class="shrink-0 text-tertiary" />
{/if}
<span class="text-xs font-medium text-primary whitespace-nowrap">
{action.label}
</span>
</button>
{#if onImportHubProject && i === 0}
<div class="mx-1 my-0.5 border-t border-gray-200 dark:border-gray-700"></div>
{/if}
{/each}
</div>
{/if}
{#if !showDoc}
<button
class="mt-1 px-2 py-1 text-left text-[10px] text-tertiary/70 hover:text-tertiary hover:underline transition-colors"
title="Show descriptions"
tabindex={-1}
onclick={() => setShowDoc(true)}
>
Show descriptions
</button>
{/if}
</div>
</div>
</div>
{/if}
<!-- shared import drawer (YAML / JSON) for the bottom "Import" submenu actions -->
<Drawer bind:this={importDrawer} size="800px">
<DrawerContent title={importTitles[importKind]} on:close={() => importDrawer?.closeDrawer?.()}>
<Tabs bind:selected={importType}>
<Tab value="yaml" label="YAML" />
<Tab value="json" label="JSON" />
{#snippet content()}
<div class="relative pt-2 h-full">
{#key importType}
{#await import('$lib/components/SimpleEditor.svelte')}
<Loader2 class="animate-spin" />
{:then Module}
<Module.default
bind:code={importRaw}
lang={importType}
class="h-full"
fixedOverflowWidgets={false}
/>
{/await}
{/key}
</div>
{/snippet}
</Tabs>
{#snippet actions()}
<Button size="sm" onClick={runImport}>Import</Button>
{/snippet}
</DrawerContent>
</Drawer>