Files
windmill/frontend/src/lib/components/ResourcePicker.svelte
T
GuilhemandClaude Opus 5 705c90debd feat(frontend): add missing resource type icons and show them in the resource picker (#10407)
* feat(frontend): add missing resource type icons and show them in the picker

Anthropic, DeepSeek, OpenRouter, Google AI (Gemini) and Ultravox resource
types rendered as a blank grey circle. Add their marks and wire up two icons
that already existed in the repo but were never imported.

Unmapped resource types now fall back to the generic resource glyph instead
of the grey circle, and the resource picker shows the icon before the path —
both in the dropdown rows and inside the input — omitting it entirely, with
no reserved padding, when the type has no icon.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): correct gemini gradient units and reuse the icon resolver

The Gemini gradient omitted gradientUnits, so its 0..24 coordinates were read
as objectBoundingBox fractions and the whole mark collapsed onto a 4% slice of
the ramp — a flat purple. Pin it to userSpaceOnUse like every other gradient
icon in the directory.

Index the resource list by path once instead of scanning it per dropdown row,
fold the two remaining inline copies of the icon lookup into appIconComponent,
and drop the sendflake key — no resource type by that name exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(frontend): shrink the resource picker icon to 14px

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style(frontend): drop the redundant frame around the ai providers list

SettingCard already supplies p-4, rounded-md and bg-surface-tertiary, so the
wrapper repeated all three and added a border, nesting a card inside a card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): keep the unregistered snowflake mark, extend the card fix

SendflakeIcon is the Snowflake logo under a misleading filename, not a
different product; SnowflakeIcon is the same logo with a baked-in ® that
shrinks and off-centres the flake and turns to a smudge at 14-24px. Leave the
mapping where it was.

Apply the card-in-card removal to the two InstanceFallbackSettings cards that
stack directly above the AI providers one, so the frames stay consistent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(frontend): make the ansible mark legible on dark surfaces

Mapping `ansible` renders this icon for the first time, and its near-black disc
disappears into the dark surface, leaving only the white "A" counter floating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:40:03 +02:00

356 lines
9.3 KiB
Svelte

<script lang="ts">
import { ResourceService, WorkspaceService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import { onMount, untrack } from 'svelte'
import AppConnect from './AppConnectDrawer.svelte'
import ResourceEditorDrawer from './ResourceEditorDrawer.svelte'
import { Button } from './common'
import { Loader2, Pen, Plus, RotateCw } from 'lucide-svelte'
import { sendUserToast } from '$lib/toast'
import Select from './select/Select.svelte'
import ExploreAssetButton, { assetCanBeExplored } from './ExploreAssetButton.svelte'
import DropdownV2 from './DropdownV2.svelte'
import { appIconComponent } from './icons'
interface Props {
initialValue?: string | undefined
value?: string | undefined
valueType?: string | undefined
resourceType?: string | undefined
disabled?: boolean
disablePortal?: boolean
showSchemaExplorer?: boolean
selectFirst?: boolean
expressOAuthSetup?: boolean
defaultValues?: Record<string, any> | undefined
placeholder?: string | undefined
selectInputClass?: string
class?: string
error?: boolean
onClear?: () => void
excludedValues?: string[]
datatableAsPgResource?: boolean
workspace?: string | undefined
disableChatOffset?: boolean
}
let {
initialValue = $bindable(undefined),
value = $bindable(undefined),
valueType = $bindable(undefined),
resourceType = undefined,
disabled = false,
disablePortal = false,
showSchemaExplorer = false,
selectFirst = false,
expressOAuthSetup = false,
defaultValues = undefined,
placeholder = undefined,
selectInputClass = '',
class: className = '',
error = false,
onClear = undefined,
excludedValues = undefined,
datatableAsPgResource = false,
workspace = undefined,
disableChatOffset = false
}: Props = $props()
let effectiveWorkspace = $derived(workspace ?? $workspaceStore!)
if (initialValue && value == undefined) {
value = initialValue
}
onMount(() => {
setTimeout(() => {
if (Object.keys(defaultValues ?? {}).length > 0 && resourceType) {
resourceEditor?.initNew?.(resourceType, defaultValues)
}
}, 500)
})
// let valueSelect = $state(
// initialValue || value
// ? {
// value: value ?? initialValue,
// label: value ?? initialValue,
// type: valueType
// }
// : undefined
// )
$effect(() => {
if (value === undefined) {
if (initialValue) {
if (initialValue != value) {
value = initialValue
}
} else {
}
} else {
}
})
let collection = $state(value ? [{ value, label: value, type: valueType }] : [])
export async function askNewResource() {
appConnect?.open?.(resourceType)
}
export async function refreshResources() {
await loadResources(resourceType)
}
let loading = $state(true)
async function loadResources(resourceType: string | undefined) {
loading = true
try {
const resourceTypesToQuery =
resourceType === 'snowflake' ? ['snowflake', 'snowflake_oauth'] : [resourceType]
const resources = await Promise.all(
resourceTypesToQuery.map((rt) =>
ResourceService.listResource({
workspace: effectiveWorkspace,
resourceType: rt
})
)
)
const nc = resources
.flat()
.filter((x) => x.resource_type != 'state' && x.resource_type != 'cache')
.filter((x) => !excludedValues || !excludedValues.includes(x.path))
.map((x) => ({
value: x.path,
label: x.path,
type: x.resource_type
}))
if (datatableAsPgResource && resourceType === 'postgresql') {
try {
const datatables = (
await WorkspaceService.listDataTables({
workspace: effectiveWorkspace
})
).map((d) => d.name)
for (const dt of datatables) {
nc.push({
value: `datatable://${dt}`,
label: `datatable://${dt}`,
type: 'postgresql'
})
}
} catch (e) {
console.error('Failed to load data tables', e)
}
}
// TODO check if this is needed
if (!nc.find((x) => x.value == value) && (initialValue || value)) {
nc.push({ value: value ?? initialValue!, label: value ?? initialValue!, type: '' })
}
collection = nc
if (collection.length == 1 && selectFirst && (value == undefined || value == '')) {
value = collection[0].value
valueType = collection[0].type
}
} catch (e) {
sendUserToast('Failed to load resource types', true)
console.error(e)
}
loading = false
}
let previousResourceType = untrack(() => resourceType)
$effect(() => {
effectiveWorkspace && resourceType
untrack(() => {
if (previousResourceType != resourceType) {
previousResourceType = resourceType
value = undefined
}
})
untrack(() => loadResources(resourceType))
})
$effect(() => {
excludedValues
if (effectiveWorkspace && resourceType && !disabled) {
untrack(() => loadResources(resourceType))
}
})
let appConnect: AppConnect | undefined = $state()
let resourceEditor: ResourceEditorDrawer | undefined = $state()
let hovering = $state(false)
let isDatatableSelected = $derived(value?.startsWith('datatable://') ?? false)
let typeByPath = $derived(new Map(collection.map((x) => [x.value, x.type])))
let SelectedIcon = $derived(appIconComponent(typeByPath.get(value!)))
</script>
<AppConnect
on:refresh={async (e) => {
await loadResources(resourceType)
value = e.detail
valueType = collection.find((x) => x?.value == value)?.type
}}
bind:this={appConnect}
{expressOAuthSetup}
{workspace}
{disableChatOffset}
/>
<ResourceEditorDrawer
bind:this={resourceEditor}
{workspace}
{disableChatOffset}
on:refresh={async (e) => {
await loadResources(resourceType)
if (e.detail) {
value = e.detail
valueType = collection.find((x) => x?.value == value)?.type
// valueSelect = { value: e.detail, label: e.detail, type: valueType ?? '' }
}
}}
/>
{#snippet selectedIconSnippet()}
{#if SelectedIcon}
<SelectedIcon height="14px" width="14px" size={14} />
{/if}
{/snippet}
<!-- {JSON.stringify({ value, collection })} -->
<div class="flex flex-col w-full items-start {className}">
<div
class="flex flex-row w-full items-center relative"
role="group"
onmouseenter={() => (hovering = true)}
onmouseleave={() => (hovering = false)}
>
<Select
{disabled}
{disablePortal}
bind:value={
() => value,
(v) => {
value = v
valueType = collection.find((x) => x?.value == v)?.type
}
}
onClear={() => {
initialValue = undefined
value = undefined
valueType = undefined
onClear?.()
}}
items={collection}
clearable
{error}
class="text-clip grow min-w-0"
inputClass={selectInputClass}
placeholder={placeholder ?? `${resourceType ?? 'any'} resource`}
itemLabelWrapperClasses="flex-1"
id="resource-picker-select"
inputLeadingSnippet={SelectedIcon ? selectedIconSnippet : undefined}
>
{#snippet startSnippet({ item })}
{@const Icon = appIconComponent(typeByPath.get(item.value))}
{#if Icon}
<span class="shrink-0 text-secondary">
<Icon height="14px" width="14px" size={14} />
</span>
{/if}
{/snippet}
{#snippet endSnippet({ item, close })}
{#if !item.value?.startsWith('datatable://')}
<Button
{disabled}
variant="subtle"
size="xs2"
wrapperClasses="-mr-2 pl-1 -my-2"
btnClasses="hover:bg-surface-tertiary"
on:click={() => (resourceEditor?.initEdit?.(item.value ?? ''), close())}
startIcon={{ icon: Pen }}
iconOnly
/>
{/if}
{/snippet}
{#snippet bottomSnippet({ close })}
<div class="flex bg-surface border-t divide-x">
{#if resourceType?.includes(',')}
<DropdownV2
enableFlyTransition
items={resourceType?.split(',').map((rt) => ({
displayName: `${rt} resource`,
icon: Plus,
action: () => (appConnect?.open?.(rt), close())
})) ?? []}
>
{#snippet buttonReplacement()}
<Button
{disabled}
color="light"
variant="contained"
wrapperClasses="flex-1"
btnClasses="rounded-none mt-0.5"
size="sm"
startIcon={{ icon: Plus }}
>
Add a resource
</Button>
{/snippet}
</DropdownV2>
{:else}
<Button
{disabled}
color="light"
variant="contained"
wrapperClasses="flex-1"
btnClasses="rounded-none"
size="sm"
on:click={() => (appConnect?.open?.(resourceType), close())}
startIcon={{ icon: Plus }}
>
Add a {resourceType} resource
</Button>
{/if}
<Button
variant="contained"
color="light"
btnClasses="rounded-none"
size="sm"
on:click={() => {
loadResources(resourceType)
}}
startIcon={loading ? { icon: Loader2, classes: 'animate-spin' } : { icon: RotateCw }}
iconOnly
/>
</div>
{/snippet}
</Select>
{#if value && hovering && !isDatatableSelected}
<div class="absolute {disabled ? 'right-2' : 'right-10'} z-20">
<Button
variant="subtle"
size="xs2"
wrapperClasses="pl-1"
btnClasses="hover:bg-surface-tertiary"
on:click={() => resourceEditor?.initEdit?.(value ?? '')}
startIcon={{ icon: Pen }}
iconOnly
/>
</div>
{/if}
</div>
{#if showSchemaExplorer && value && assetCanBeExplored({ kind: 'resource', path: value }, { resource_type: resourceType })}
<ExploreAssetButton
class="mt-1"
_resourceMetadata={{ resource_type: resourceType }}
asset={{ kind: 'resource', path: value }}
workspace={effectiveWorkspace}
/>
{/if}
</div>