feat: add homepage connect drawer (#8880)

* feat: add homepage connect drawer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: reset connect drawer state

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: polish home connect button

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use standard home connect button style

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
centdix
2026-04-20 13:07:57 +00:00
committed by GitHub
co-authored by Claude Opus 4.5
parent 46b2915a9d
commit f35e10cc0a
4 changed files with 311 additions and 80 deletions
@@ -9,9 +9,19 @@
language: LanguageType<string>
disabled?: boolean
wrap?: boolean
copyOnClick?: boolean
}
let { code, language, disabled = false, wrap = false }: Props = $props()
let { code, language, disabled = false, wrap = false, copyOnClick = true }: Props = $props()
function copyCode(event?: MouseEvent) {
if (disabled) {
return
}
event?.preventDefault()
event?.stopPropagation()
copyToClipboard(code)
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
@@ -19,22 +29,24 @@
<div
class="flex flex-col flex-1 border rounded-md relative bg-surface-input"
class:cursor-not-allowed={disabled}
class:cursor-pointer={copyOnClick && !disabled}
class:cursor-text={!copyOnClick && !disabled}
onclick={(e) => {
if (disabled) {
return
if (copyOnClick) {
copyCode(e)
}
e.preventDefault()
copyToClipboard(code)
}}
>
<div class="absolute top-2 right-1 z-10 pointer-events-none">
<Copy size={14} class="w-8 cursor-pointer pointer-events-auto" />
<div class="absolute top-2 right-1 z-10">
<div class="w-8 cursor-pointer" onclick={copyCode}>
<Copy size={14} />
</div>
</div>
<div class="p-2 w-full overflow-auto">
<Highlight
{language}
{code}
class="pointer-events-none {wrap ? 'whitespace-pre-wrap break-all pr-8' : ''}"
class="{copyOnClick ? 'pointer-events-none' : 'select-text'} {wrap ? 'whitespace-pre-wrap break-all pr-8' : ''}"
/>
</div>
</div>
@@ -0,0 +1,133 @@
<script lang="ts">
import { workspaceStore } from '$lib/stores'
import { Button, Drawer, DrawerContent, Tab, TabContent, Tabs } from '$lib/components/common'
import CreateToken from '$lib/components/settings/CreateToken.svelte'
import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte'
import { Bot, ExternalLink, Terminal } from 'lucide-svelte'
import { shell } from 'svelte-highlight/languages'
type ConnectTab = 'cli' | 'mcp'
let drawer: Drawer | undefined = $state()
let selectedTab: ConnectTab = $state('cli')
let openVersion = $state(0)
const origin = $derived(typeof window === 'undefined' ? '' : window.location.origin)
const workspaceId = $derived($workspaceStore ?? '<workspace>')
const cliCommands = $derived(`npm install -g windmill-cli
wmill workspace add ${workspaceId} ${workspaceId} ${origin}
wmill init
wmill sync pull`)
function noop() {}
export function openDrawer(tab: ConnectTab = 'cli') {
selectedTab = tab
openVersion += 1
drawer?.openDrawer()
}
function closeDrawer() {
drawer?.closeDrawer()
}
</script>
<Drawer bind:this={drawer} size="720px">
<DrawerContent title="Connect this workspace" on:close={closeDrawer}>
<div class="flex flex-col gap-5 pb-4">
<div class="flex flex-col gap-2">
<div class="w-full">
<Tabs values={['cli', 'mcp']} bind:selected={selectedTab} wrapperClass="scrollbar-hidden">
<Tab value="cli" label="CLI" icon={Terminal} />
<Tab value="mcp" label="MCP" icon={Bot} />
{#snippet content()}
<div class="pt-4">
<TabContent value="cli">
<div class="flex flex-col gap-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis">Local setup</h3>
<p class="text-xs text-secondary max-w-xl">
Run this in your local repo to bind the current workspace, create
<code class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis"
>wmill.yaml</code
>, and pull the latest files.
</p>
</div>
<Button
variant="subtle"
unifiedSize="sm"
href="https://www.windmill.dev/docs/advanced/cli"
target="_blank"
startIcon={{ icon: ExternalLink }}
>
CLI docs
</Button>
</div>
<CopyableCodeBlock
code={cliCommands}
language={shell}
wrap
copyOnClick={false}
/>
<p class="text-2xs text-secondary">
<code class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis"
>wmill workspace add</code
>
will handle authentication,
<code class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis"
>wmill init</code
>
bootstraps the local config, and
<code class="rounded bg-surface-secondary px-1 py-0.5 font-mono text-2xs text-emphasis"
>wmill sync pull</code
>
fetches the workspace content.
</p>
</div>
</TabContent>
<TabContent value="mcp">
<div class="flex flex-col gap-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="flex flex-col gap-1">
<h3 class="text-sm font-semibold text-emphasis">MCP URL</h3>
<p class="text-xs text-secondary max-w-xl">
Generate an MCP server URL for the current workspace and choose which
scripts, flows, and endpoints the client can access.
</p>
</div>
<Button
variant="subtle"
unifiedSize="sm"
href="https://www.windmill.dev/docs/core_concepts/mcp"
target="_blank"
startIcon={{ icon: ExternalLink }}
>
MCP docs
</Button>
</div>
{#key openVersion}
<CreateToken
mcpOnly
lockWorkspace
title="Generate MCP URL"
defaultNewTokenWorkspace={$workspaceStore}
onTokenCreated={noop}
/>
{/key}
</div>
</TabContent>
</div>
{/snippet}
</Tabs>
</div>
</div>
</div>
</DrawerContent>
</Drawer>
@@ -15,6 +15,9 @@
interface Props {
showMcpMode?: boolean
openWithMcpMode?: boolean
mcpOnly?: boolean
lockWorkspace?: boolean
title?: string
newTokenLabel?: string
defaultNewTokenWorkspace?: string
scopes?: string[]
@@ -24,6 +27,10 @@
let {
showMcpMode = false,
openWithMcpMode = false,
mcpOnly = false,
lockWorkspace = false,
title = 'Add a new token',
defaultNewTokenWorkspace,
scopes,
onTokenCreated,
@@ -37,6 +44,8 @@
let newTokenWorkspace = $state<string | undefined>(untrack(() => defaultNewTokenWorkspace))
let mcpCreationMode = $state(false)
let mcpScope = $state('mcp:favorites')
let lastRequestedMcpMode = $state<boolean | undefined>(undefined)
let mcpLabelAutofilled = $state(false)
let customScopes = $state<string[]>([])
let showCustomScopes = $state(false)
@@ -55,6 +64,31 @@
return [{ id: currentWorkspace, name: currentWorkspace }, ...workspacesList]
}
function enterMcpMode() {
mcpCreationMode = true
newTokenExpiration = undefined
newTokenWorkspace = defaultNewTokenWorkspace ?? $workspaceStore
newToken = undefined
newMcpToken = undefined
if (!newTokenLabel) {
newTokenLabel = 'MCP token'
mcpLabelAutofilled = true
} else {
mcpLabelAutofilled = false
}
}
function exitMcpMode() {
mcpCreationMode = false
newTokenExpiration = undefined
newTokenWorkspace = defaultNewTokenWorkspace
newMcpToken = undefined
if (mcpLabelAutofilled) {
newTokenLabel = undefined
}
mcpLabelAutofilled = false
}
async function createToken(mcpMode: boolean = false): Promise<void> {
try {
let date: Date | undefined
@@ -79,13 +113,17 @@
})
if (mcpMode) {
newToken = undefined
newMcpToken = `${createdToken}`
} else {
newMcpToken = undefined
newToken = `${createdToken}`
}
onTokenCreated(newToken ?? newMcpToken ?? '')
mcpCreationMode = false
onTokenCreated(`${createdToken}`)
if (!mcpOnly) {
mcpCreationMode = false
}
} catch (err) {
console.error('Failed to create token:', err)
}
@@ -93,13 +131,34 @@
const workspaces = $derived(ensureCurrentWorkspaceIncluded($userWorkspaces, $workspaceStore))
const mcpBaseUrl = $derived(`${window.location.origin}/api/mcp/w/${newTokenWorkspace}/mcp?token=`)
$effect(() => {
const requestedMcpMode = mcpOnly || openWithMcpMode
if (requestedMcpMode === lastRequestedMcpMode) {
return
}
if (requestedMcpMode) {
enterMcpMode()
} else {
exitMcpMode()
}
lastRequestedMcpMode = requestedMcpMode
})
$effect(() => {
if (mcpLabelAutofilled && newTokenLabel !== 'MCP token') {
mcpLabelAutofilled = false
}
})
</script>
<div>
<div class="p-4 rounded-md mb-6 min-w-min bg-surface-tertiary">
<h3 class="pb-2 font-semibold text-emphasis text-sm">Add a new token</h3>
<h3 class="pb-2 font-semibold text-emphasis text-sm">{title}</h3>
{#if showMcpMode}
{#if showMcpMode && !mcpOnly}
<div
class="mb-4 flex flex-row flex-shrink-0"
use:triggerableByAI={{
@@ -109,15 +168,10 @@
>
<Toggle
on:change={(e) => {
mcpCreationMode = e.detail
if (e.detail) {
newTokenLabel = 'MCP token'
newTokenExpiration = undefined
newTokenWorkspace = $workspaceStore
enterMcpMode()
} else {
newTokenLabel = undefined
newTokenExpiration = undefined
newTokenWorkspace = defaultNewTokenWorkspace
exitMcpMode()
}
}}
checked={mcpCreationMode}
@@ -162,70 +216,76 @@
{/if}
<div class="mt-2 grid grid-cols-1 md:grid-cols-2 gap-4">
{#if mcpCreationMode}
<div class="col-span-2">
<McpScopeSelector
workspaceId={newTokenWorkspace || $workspaceStore || ''}
bind:scope={mcpScope}
/>
</div>
{#if mcpCreationMode}
<div class="col-span-2">
<McpScopeSelector
workspaceId={newTokenWorkspace || $workspaceStore || ''}
bind:scope={mcpScope}
/>
</div>
<div>
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
<Select
bind:value={newTokenWorkspace}
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
/>
</div>
{/if}
{#if !lockWorkspace}
<div>
<span class="block mb-1 text-emphasis text-xs font-semibold">Workspace</span>
<Select
bind:value={newTokenWorkspace}
items={workspaces.map((w) => ({ label: w.name, value: w.id, subtitle: w.id }))}
/>
</div>
{/if}
{/if}
<div>
<span class="block mb-1 text-emphasis text-xs font-semibold"
>Label <span class="text-xs text-primary">(optional)</span></span
>
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
</div>
{#if !mcpOnly}
<div>
<span class="block mb-1 text-emphasis text-xs font-semibold"
>Label <span class="text-xs text-primary">(optional)</span></span
>
<TextInput inputProps={{ type: 'text' }} bind:value={newTokenLabel} class="w-full" />
</div>
{/if}
{#if !mcpCreationMode}
<div>
<span class="block mb-1 text-xs text-emphasis font-semibold"
>Expires In <span class="text-xs text-primary">(optional)</span></span
>
<Select
bind:value={newTokenExpiration}
placeholder="No expiration"
inputClass="w-full"
items={[
{ label: 'No expiration', value: undefined },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 1 * 60 * 60 },
{ label: '1 day', value: 1 * 24 * 60 * 60 },
{ label: '7 days', value: 7 * 24 * 60 * 60 },
{ label: '30 days', value: 30 * 24 * 60 * 60 },
{ label: '90 days', value: 90 * 24 * 60 * 60 }
]}
/>
</div>
{/if}
{#if !mcpCreationMode}
<div>
<span class="block mb-1 text-xs text-emphasis font-semibold"
>Expires In <span class="text-xs text-primary">(optional)</span></span
>
<Select
bind:value={newTokenExpiration}
placeholder="No expiration"
inputClass="w-full"
items={[
{ label: 'No expiration', value: undefined },
{ label: '15 minutes', value: 15 * 60 },
{ label: '30 minutes', value: 30 * 60 },
{ label: '1 hour', value: 1 * 60 * 60 },
{ label: '1 day', value: 1 * 24 * 60 * 60 },
{ label: '7 days', value: 7 * 24 * 60 * 60 },
{ label: '30 days', value: 30 * 24 * 60 * 60 },
{ label: '90 days', value: 90 * 24 * 60 * 60 }
]}
/>
</div>
{/if}
</div>
<div class="mt-4 flex justify-end gap-2 flex-row">
<Button
on:click={() => {
mcpCreationMode = false
}}
variant="default"
>
Cancel
</Button>
{#if !mcpOnly}
<Button
on:click={() => {
exitMcpMode()
}}
variant="default"
>
Cancel
</Button>
{/if}
<Button
on:click={() => createToken(mcpCreationMode)}
disabled={mcpCreationMode &&
(newTokenWorkspace == undefined || !mcpScope || mcpScope.trim().length === 0)}
variant="accent"
>
New token
{mcpCreationMode ? 'Generate MCP URL' : 'New token'}
</Button>
</div>
</div>
@@ -13,6 +13,7 @@
import PickHubScript from '$lib/components/flows/pickers/PickHubScript.svelte'
import PickHubFlow from '$lib/components/flows/pickers/PickHubFlow.svelte'
import HighlightCode from '$lib/components/HighlightCode.svelte'
import HomeConnectDrawer from '$lib/components/home/HomeConnectDrawer.svelte'
import {
Building,
ExternalLink,
@@ -20,7 +21,8 @@
Globe2,
Loader2,
Code,
LayoutDashboard
LayoutDashboard,
PlugZap
} from 'lucide-svelte'
import { hubBaseUrlStore } from '$lib/stores'
import { base } from '$lib/base'
@@ -98,6 +100,7 @@
}
let workspaceTutorials: WorkspaceTutorials | undefined = $state(undefined)
let homeConnectDrawer: HomeConnectDrawer | undefined = $state(undefined)
// Provide workspaceTutorials to child components via a reactive wrapper
let workspaceTutorialsContext = $derived(workspaceTutorials)
@@ -283,8 +286,18 @@
title="Home"
childrenWrapperDivClasses="flex-1 flex flex-row gap-4 flex-wrap justify-end items-center"
>
{#if $userStore?.operator}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: PlugZap }}
btnClasses="whitespace-nowrap"
onClick={() => homeConnectDrawer?.openDrawer?.()}
>
CLI / MCP
</Button>
{/if}
{#if !$userStore?.operator && showCreateButtons}
<span class="text-xs font-normal text-primary">Create a</span>
<CreateActionsScript aiId="create-script-button" aiDescription="Creates a new script" />
{#if HOME_SHOW_CREATE_FLOW}<CreateActionsFlow />{/if}
{#if HOME_SHOW_CREATE_APP}<CreateActionsApp />{/if}
@@ -296,13 +309,25 @@
<NoDirectDeployAlert onUpdateCanEditStatus={(v) => showCreateButtons = v}/>
{#if !$userStore?.operator}
<div class="w-full overflow-auto scrollbar-hidden pb-2">
<Tabs values={['hub', 'workspace']} hashNavigation bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building} />
{#if HOME_SHOW_HUB}
<Tab value="hub" label="Hub" icon={Globe2} />
{/if}
</Tabs>
<div class="flex w-full items-center gap-3 pb-2">
<div class="min-w-0 flex-1 overflow-auto scrollbar-hidden">
<Tabs values={['hub', 'workspace']} hashNavigation bind:selected={tab}>
<Tab value="workspace" label="Workspace" icon={Building} />
{#if HOME_SHOW_HUB}
<Tab value="hub" label="Hub" icon={Globe2} />
{/if}
</Tabs>
</div>
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: PlugZap }}
btnClasses="whitespace-nowrap shrink-0"
onClick={() => homeConnectDrawer?.openDrawer?.()}
>
CLI / MCP
</Button>
</div>
{/if}
{#if tab == 'hub'}
@@ -374,3 +399,4 @@
</div>
<WorkspaceTutorials bind:this={workspaceTutorials} />
<HomeConnectDrawer bind:this={homeConnectDrawer} />