Files
windmill/frontend/src/lib/components/ContentSearchInner.svelte
T
Ruben FiszelandClaude Opus 5 529e960629 perf: cap resource content sent to the search modal (#10714)
* perf: cap resource content sent to the search modal

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

* fix: address review — fence the LATERAL, flag partial search, add cap test

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

* fix: pluralize the truncation notice and link the cap to its openapi doc

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 17:06:07 +02:00

406 lines
11 KiB
Svelte

<script lang="ts">
import { AppService, FlowService, ResourceService, ScriptService } from '$lib/gen'
import { workspaceStore } from '$lib/stores'
import {
ArrowDown,
Boxes,
Code2,
Edit,
ExternalLink,
LayoutDashboard,
Loader2
} from 'lucide-svelte'
import SearchItems from './SearchItems.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import FlowIcon from './home/FlowIcon.svelte'
import Tooltip from './meltComponents/Tooltip.svelte'
import { Badge, Button } from './common'
import YAML from 'yaml'
import { twMerge } from 'tailwind-merge'
import ContentSearchInnerItem from './ContentSearchInnerItem.svelte'
import { createEventDispatcher, untrack } from 'svelte'
const dispatch = createEventDispatcher()
interface Props {
search?: string
}
let { search = $bindable('') }: Props = $props()
export async function open(nsearch?: string) {
await Promise.all([loadScripts(), loadResources(), loadApps(), loadFlows()])
if (nsearch) {
search = nsearch
}
}
export async function loadScripts() {
scripts = await ScriptService.listSearchScript({ workspace: $workspaceStore ?? '' })
}
export async function loadResources() {
resources = await ResourceService.listSearchResource({ workspace: $workspaceStore ?? '' })
}
export async function loadApps() {
apps = await AppService.listSearchApp({ workspace: $workspaceStore ?? '' })
}
export async function loadFlows() {
flows = await FlowService.listSearchFlow({ workspace: $workspaceStore ?? '' })
}
let searchKind: 'all' | 'scripts' | 'flows' | 'apps' | 'resources' = $state('all')
let scripts: undefined | { path: string; content: string }[] = $state(undefined)
let filteredScriptItems: { path: string; content: string; marked: any }[] = $state([])
// Resource values are arbitrary user JSON and can be huge, so the API sends them already
// rendered and length-capped. Keep them as text — re-serializing here blocks the main
// thread for seconds on workspaces with many large resources.
type ResourceHit = { path: string; value: string; truncated: boolean }
let resources: undefined | ResourceHit[] = $state(undefined)
let filteredResourceItems: (ResourceHit & { marked: any })[] = $state([])
let flows: undefined | { path: string; value: any }[] = $state(undefined)
let filteredFlowItems: { path: string; value: any; marked: any }[] = $state([])
let apps: undefined | { path: string; value: any }[] = $state(undefined)
let filteredAppItems: { path: string; value: any; marked: any }[] = $state([])
function getCounts(n: number) {
return ` (${n})`
}
let showNbScripts = $state(10)
let showNbApps = $state(10)
let showNbResources = $state(10)
let showNbFlows = $state(10)
function resetShows() {
showNbScripts = 10
showNbApps = 10
showNbResources = 10
showNbFlows = 10
}
let counts = $derived(
search == '' ||
!scripts ||
!resources ||
!flows ||
!apps ||
!filteredAppItems ||
!filteredFlowItems ||
!filteredResourceItems ||
!filteredScriptItems
? {
all: '',
apps: '',
flows: '',
resources: '',
scripts: ''
}
: {
all: getCounts(
filteredAppItems.length +
filteredFlowItems.length +
filteredResourceItems.length +
filteredScriptItems.length
),
apps: getCounts(filteredAppItems.length),
resources: getCounts(filteredResourceItems.length),
flows: getCounts(filteredFlowItems.length),
scripts: getCounts(filteredScriptItems.length)
}
)
$effect(() => {
search && untrack(() => resetShows())
})
</script>
<SearchItems
filter={search}
items={scripts}
f={(s) => {
return s.content
}}
bind:filteredItems={filteredScriptItems}
/>
<SearchItems
filter={search}
items={resources}
f={(s) => {
return s.value
}}
bind:filteredItems={filteredResourceItems}
/>
<SearchItems
filter={search}
items={flows}
f={(s) => {
return YAML.stringify(s.value, null, 4)
}}
bind:filteredItems={filteredFlowItems}
/>
<SearchItems
filter={search}
items={apps}
f={(s) => {
return YAML.stringify(s.value, null, 4)
}}
bind:filteredItems={filteredAppItems}
/>
<div class="flex flex-col gap-2">
<div class="flex gap-2 flex-wrap sticky top-0 left-0 right-0 bg-surface">
<div class="p-2">
<ToggleButtonGroup bind:selected={searchKind}>
{#snippet children({ item })}
<ToggleButton small value="all" label={'All' + counts.all} {item} />
<ToggleButton
small
value="scripts"
icon={Code2}
label={'Scripts' + counts.scripts}
{item}
/>
<ToggleButton
small
value="resources"
icon={Boxes}
label={'Resources' + counts.resources}
{item}
/>
<ToggleButton
small
value="flows"
label={'Flows' + counts.flows}
icon={FlowIcon}
selectedColor="#14b8a6"
{item}
/>
<ToggleButton
small
value="apps"
label={'Apps' + counts.apps}
icon={LayoutDashboard}
selectedColor="#fb923c"
{item}
/>
{/snippet}
</ToggleButtonGroup>
</div>
</div>
<div class="px-2">
<div class="text-xs text-secondary">
Searching among <div class="inline-flex">
{#if scripts}{scripts?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}
</div>
scripts,
<div class="inline-flex">
{#if resources}{resources?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}
</div>
resources,
<div class="inline-flex">
{#if flows}{flows?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}
</div>
flows,
<div class="inline-flex">
{#if apps}{apps?.length}{:else}
<Loader2 size={10} class="animate-spin " />
{/if}
</div>
apps
</div>
{#if resources}
{@const nTruncated = resources.filter((r) => r.truncated).length}
{#if nTruncated > 0}
<!-- A resource whose only match sits past the API's cap drops out of the results with
nothing to show, so say up front how much of the corpus is only partly searched. -->
<div class="text-xs text-secondary">
{nTruncated} of those resources {nTruncated === 1 ? 'is' : 'are'} too large to search in full
— only {nTruncated === 1 ? 'its' : 'their'} beginning is matched.
</div>
{/if}
{/if}
</div>
<div class={twMerge('p-2')}>
{#if search.trim().length > 0}
<div class="flex flex-col gap-4">
{#if (searchKind == 'all' || searchKind == 'scripts') && filteredScriptItems?.length > 0}
{#each filteredScriptItems.slice(0, showNbScripts) ?? [] as item}
<ContentSearchInnerItem
title={`Script: ${item.path}`}
href={`/scripts/get/${item.path}`}
on:close
>
{#snippet actions()}
<Button
href={`/scripts/get/${item.path}`}
startIcon={{ icon: ExternalLink }}
on:click={() => dispatch('close')}
>
Open
</Button>
<Button
href={`/scripts/edit/${item.path}?no_draft=true`}
target="_blank"
startIcon={{ icon: Edit }}
>
Edit
</Button>
{/snippet}
<pre class="text-xs border rounded-md p-2 overflow-auto max-h-40 w-full"
><code>{@html item.marked}</code>
</pre>
</ContentSearchInnerItem>
{/each}
{#if filteredScriptItems.length > showNbScripts}
<Button
color="light"
on:click={() => (showNbScripts += 30)}
startIcon={{ icon: ArrowDown }}
>
Show more scripts ({showNbScripts} of {filteredScriptItems.length})
</Button>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'resources') && filteredResourceItems?.length > 0}
{#each filteredResourceItems.slice(0, showNbResources) ?? [] as item}
<ContentSearchInnerItem
title={`Resource: ${item.path}`}
href={`/resources#${item.path}`}
on:close
>
{#snippet actions()}
{#if item.truncated}
<Tooltip>
<Badge color="gray">Truncated</Badge>
{#snippet text()}
This resource is too large to search in full: only its beginning is matched
and shown.
{/snippet}
</Tooltip>
{/if}
<Button href={`/resources#${item.path}`} target="_blank" startIcon={{ icon: Edit }}>
Edit
</Button>
{/snippet}
<pre class="text-xs border rounded-md p-2 overflow-auto max-h-40 w-full"
><code>{@html item.marked}</code></pre
>
</ContentSearchInnerItem>
{/each}
{#if filteredResourceItems.length > showNbResources}
<Button
color="light"
size="xs"
on:click={() => (showNbResources += 30)}
startIcon={{ icon: ArrowDown }}
>
Show more resources ({showNbResources} of {filteredResourceItems.length})
</Button>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'flows') && filteredFlowItems?.length > 0}
{#each filteredFlowItems.slice(0, showNbFlows) ?? [] as item}
<ContentSearchInnerItem
title={`Flow: ${item.path}`}
href={`/flows/get/${item.path}`}
on:close
>
{#snippet actions()}
<Button
href={`/flows/get/${item.path}`}
startIcon={{ icon: ExternalLink }}
on:click={() => dispatch('close')}
>
Open
</Button>
<Button
href={`/flows/edit/${item.path}?no_draft=true`}
target="_blank"
startIcon={{ icon: Edit }}
>
Edit
</Button>
{/snippet}
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full"
><code>{@html item.marked}</code></pre
>
</ContentSearchInnerItem>
{/each}
{#if filteredFlowItems.length > showNbFlows}
<Button
color="light"
size="xs"
on:click={() => (showNbFlows += 30)}
startIcon={{ icon: ArrowDown }}
>
Show more flows ({showNbFlows} of {filteredFlowItems.length})
</Button>
{/if}
{/if}
{#if (searchKind == 'all' || searchKind == 'apps') && filteredAppItems?.length > 0}
{#each filteredAppItems.slice(0, showNbApps) ?? [] as item}
<ContentSearchInnerItem
title={`App: ${item.path}`}
href={`/apps/get/${item.path}`}
on:close
>
{#snippet actions()}
<Button
href={`/apps/get/${item.path}`}
startIcon={{ icon: ExternalLink }}
on:click={() => dispatch('close')}
>
Open
</Button>
<Button href={`/apps/edit/${item.path}`} target="_blank" startIcon={{ icon: Edit }}>
Edit
</Button>
{/snippet}
<pre class="text-xs border p-2 overflow-auto max-h-40 w-full"
><code>{@html item.marked}</code></pre
>
</ContentSearchInnerItem>
{/each}
{#if filteredAppItems.length > showNbApps}
<Button
color="light"
size="xs"
on:click={() => (showNbApps += 30)}
startIcon={{ icon: ArrowDown }}
>
Show more apps ({showNbApps} of {filteredAppItems.length})
</Button>
{/if}
{/if}
</div>
{:else}
<div class="flex justify-center items-center h-48">
<div class="text-primary text-center">
<div class="text-2xl font-bold">Empty Search Filter</div>
<div class="text-sm"
>Start writing, search everywhere a path is referenced for instance</div
>
</div>
</div>
{/if}
</div>
</div>