Files
windmill/frontend/src/lib/components/PathNameAutocomplete.svelte
T
GuilhemandClaude Opus 4.8 c000bbca28 fix(frontend): scope raw-app, flow and script editors to the session workspace (#10015)
* fix(frontend): scope raw-app/flow/script editors to the session workspace

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

* fix(frontend): scope flow and script editor operations to the session workspace

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

* fix(frontend): scope flow preview, inline-script creation and datatable schema to the session workspace

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

* fix(frontend): address Codex review — thread session workspace through flow resource pickers, script fetch, preview cancel/recording and path collision check

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

* fix(frontend): address Claude review — pass session workspace to preview FlowStatusViewer and align FlowChatManager guards

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

* fix(frontend): address Pi review — show acting workspace in script-not-found message and fetch picked script from it in EditorBar

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

* fix(frontend): address Codex review round 2 — thread session workspace into flow step test, raw-app inline runnable, inline editor toolbars and MCP OAuth path

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

* fix(frontend): address Codex review round 3 — thread session workspace into dynamic-input helpers and the flow-preview argument side panel (history/saved-inputs/captures)

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

* fix(frontend): address Codex review round 4 — thread session workspace into nested flow/script drawers, flow chat inputs and the flow input side tabs

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

* fix(frontend): address Codex review round 5 — thread session workspace into script-module fork/reload and key the raw-app schema cache by workspace

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

* fix(frontend): address Codex review round 6 — key the DB manager schema cache by acting workspace

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

* fix(frontend): address Codex review round 7 — thread session workspace into resource-valued arg pickers and the editor variable/resource helper drawers

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

* fix(frontend): scope the flow asset explorer's ResourceEditorDrawer to the acting workspace

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

* fix: thread acting workspace through flow asset explore controls

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

* fix: thread acting workspace through SQL REPL, secret args, helper forms, S3 inputs, saved inputs

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 01:53:43 +02:00

389 lines
11 KiB
Svelte

<script module lang="ts">
import type { ButtonType } from '$lib/components/common/button/model'
import { PathAutocompleteService } from '$lib/gen'
type InputSize = ButtonType.UnifiedSize
export type AutocompleteSegment = { name: string; is_folder: boolean }
/** Client-side TTL for the workspace path list (mirrors backend cache). */
const PATH_LIST_TTL_MS = 60_000
type Entry = {
at: number
paths: string[] | null
pending: Promise<string[]> | null
}
/** Module-level shared cache so multiple PathNameAutocomplete instances on
* the same page reuse a single fetch per workspace. */
const pathListCache = new Map<string, Entry>()
/** Workspaces whose next fetch must bypass the server-side cache. Set by
* invalidateWorkspacePaths (e.g. after a deploy) and cleared once a forced
* fetch succeeds, so a just-created path shows up immediately instead of
* after the backend's 60s TTL. */
const forceNextFetch = new Set<string>()
export async function fetchWorkspacePaths(workspace: string): Promise<string[]> {
const force = forceNextFetch.has(workspace)
const now = Date.now()
const existing = pathListCache.get(workspace)
// When forcing, ignore any cached/in-flight entry — it may predate the
// deploy (or have been written by a fetch that hit the stale backend
// cache) and would otherwise mask the new path.
if (existing && !force) {
if (existing.paths && now - existing.at < PATH_LIST_TTL_MS) return existing.paths
if (existing.pending) return existing.pending
}
const pending = (async () => {
try {
const res = await PathAutocompleteService.listPathAutocompletePaths({ workspace, force })
const paths = res.paths ?? []
pathListCache.set(workspace, { at: Date.now(), paths, pending: null })
// Only clear the force flag once a forced fetch has actually
// landed fresh data, so a failed retry still forces.
forceNextFetch.delete(workspace)
return paths
} catch (_e) {
pathListCache.delete(workspace)
return []
}
})()
pathListCache.set(workspace, { at: now, paths: null, pending })
return pending
}
export function invalidateWorkspacePaths(workspace: string) {
pathListCache.delete(workspace)
forceNextFetch.add(workspace)
}
/** Derive the set of path segments that exist directly under a given folder
* prefix, from the flat list of all workspace paths. */
export function segmentsUnderPrefix(
paths: string[],
folderPrefix: string
): AutocompleteSegment[] {
const seen = new Map<string, boolean>()
const prefixLen = folderPrefix.length
for (const p of paths) {
if (!p.startsWith(folderPrefix)) continue
const remainder = p.slice(prefixLen)
if (remainder.length === 0) continue
const slash = remainder.indexOf('/')
if (slash >= 0) {
seen.set(remainder.slice(0, slash), true)
} else if (!seen.has(remainder)) {
seen.set(remainder, false)
}
}
return Array.from(seen, ([name, is_folder]) => ({ name, is_folder })).sort((a, b) =>
a.name.localeCompare(b.name)
)
}
</script>
<script lang="ts">
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Badge from '$lib/components/common/badge/Badge.svelte'
import { workspaceStore } from '$lib/stores'
import { untrack } from 'svelte'
type Props = {
prefix: string
value?: string
placeholder?: string
disabled?: boolean
autofocus?: boolean
id?: string
size?: InputSize
error?: string | boolean
textInputClass?: string
onkeyup?: (e: KeyboardEvent) => void
/** Workspace whose paths feed the autocomplete. Defaults to the navigation
* `$workspaceStore`; pass the acting workspace when the editor operates on
* a workspace other than the one the top nav points at. */
workspace?: string
}
let {
prefix,
value = $bindable(),
placeholder = '',
disabled = false,
autofocus = false,
id,
size = 'md',
error,
textInputClass,
onkeyup,
workspace = undefined
}: Props = $props()
let ws = $derived(workspace ?? $workspaceStore)
let inputEl: TextInput | undefined = $state(undefined)
export function focus() {
inputEl?.focus()
}
let allPaths = $state<string[]>([])
let cycleMode = $state<{
cyclePrefix: string
options: AutocompleteSegment[]
index: number
} | null>(null)
let dismissedFor: string | undefined = $state(undefined)
let hasFocus = $state(false)
// Flag set when keydown consumes Enter for ghost-text acceptance.
// Checked in keyup to suppress Path.svelte's 'enter' dispatch.
let enterConsumed = false
let currentSegment = $derived.by(() => {
const v = value ?? ''
const idx = v.lastIndexOf('/')
return idx >= 0 ? v.slice(idx + 1) : v
})
let folderPrefix = $derived.by(() => {
const v = value ?? ''
const idx = v.lastIndexOf('/')
return prefix + (idx >= 0 ? v.slice(0, idx + 1) : '')
})
let fullPrefix = $derived(prefix + (value ?? ''))
let derivedFolderMatches = $derived.by(() => {
if (allPaths.length === 0) return []
return segmentsUnderPrefix(allPaths, folderPrefix).filter(
(s) => s.is_folder && s.name.startsWith(currentSegment)
)
})
let displayedOptions = $derived(cycleMode ? cycleMode.options : derivedFolderMatches)
let displayedActiveIndex = $derived(cycleMode ? cycleMode.index : -1)
// Ghost text: longest common prefix across all matching folder names,
// beyond what the user has already typed. Shows the unambiguous part
// the user can accept with Enter (multi-match) or Tab (single match).
let ghostText = $derived.by(() => {
if (cycleMode) return ''
if (derivedFolderMatches.length === 0) return ''
const names = derivedFolderMatches.map((s) => s.name)
// Compute LCP of all matching folder names
let lcp = names[0]
for (let i = 1; i < names.length; i++) {
let j = 0
while (j < lcp.length && j < names[i].length && lcp[j] === names[i][j]) j++
lcp = lcp.slice(0, j)
}
if (lcp.length <= currentSegment.length) return ''
const completion = lcp.slice(currentSegment.length)
// Append '/' only when the LCP fully resolves to one folder name
const trailingSlash = derivedFolderMatches.length === 1 ? '/' : ''
return completion + trailingSlash
})
let showList = $derived(
!disabled && dismissedFor !== fullPrefix && hasFocus && displayedOptions.length > 0
)
function applyCycleOrComplete(direction: 1 | -1): boolean {
if (cycleMode) {
const n = cycleMode.options.length
const nextIdx = (cycleMode.index + direction + n) % n
cycleMode = { ...cycleMode, index: nextIdx }
value = cycleMode.cyclePrefix + cycleMode.options[nextIdx].name + '/'
return true
}
if (derivedFolderMatches.length === 0) return false
const opts = [...derivedFolderMatches]
const vNow = value ?? ''
const slash = vNow.lastIndexOf('/')
const cyclePrefix = slash >= 0 ? vNow.slice(0, slash + 1) : ''
if (opts.length === 1) {
value = cyclePrefix + opts[0].name + '/'
return true
}
const idx = direction === 1 ? 0 : opts.length - 1
cycleMode = { cyclePrefix, options: opts, index: idx }
value = cyclePrefix + opts[idx].name + '/'
return true
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
const dir: 1 | -1 = e.shiftKey ? -1 : 1
if (applyCycleOrComplete(dir)) {
e.preventDefault()
return
}
return
}
// Enter picks the currently highlighted folder (from Tab cycling)
// or the first match, and navigates into it.
if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey) {
let pick: AutocompleteSegment | undefined
let committed = ''
if (cycleMode) {
pick = cycleMode.options[cycleMode.index]
committed = cycleMode.cyclePrefix
} else if (derivedFolderMatches.length > 0) {
pick = derivedFolderMatches[0]
const vNow = value ?? ''
const slash = vNow.lastIndexOf('/')
committed = slash >= 0 ? vNow.slice(0, slash + 1) : ''
}
if (pick) {
e.preventDefault()
e.stopPropagation()
enterConsumed = true
value = committed + pick.name + '/'
cycleMode = null
return
}
}
if (e.key === 'Escape' && (cycleMode || showList || ghostText)) {
e.stopPropagation()
cycleMode = null
dismissedFor = fullPrefix
return
}
}
function selectOption(opt: AutocompleteSegment) {
const vNow = value ?? ''
const slash = vNow.lastIndexOf('/')
const valueCommitted = slash >= 0 ? vNow.slice(0, slash + 1) : ''
value = valueCommitted + opt.name + '/'
cycleMode = null
inputEl?.focus()
}
async function loadPaths(workspace: string) {
const paths = await fetchWorkspacePaths(workspace)
// Guard against workspace changing during the in-flight fetch.
if (ws === workspace) allPaths = paths
}
$effect(() => {
const w = ws
if (w) void loadPaths(w)
})
$effect(() => {
void value
void prefix
// Re-arm after the dismissed prefix is left.
if (untrack(() => dismissedFor) !== undefined) {
const curr = untrack(() => fullPrefix)
if (untrack(() => dismissedFor) !== curr) dismissedFor = undefined
}
// Exit cycle mode if the value no longer matches the cycled option.
const cm = untrack(() => cycleMode)
if (cm) {
const expected = cm.cyclePrefix + cm.options[cm.index].name + '/'
if ((value ?? '') !== expected) cycleMode = null
}
})
const overlayPaddingX: Record<InputSize, string> = {
'2xs': 'px-1',
xs: 'px-1',
sm: 'px-2',
md: 'px-2',
lg: 'px-2'
}
const overlayHeight: Record<InputSize, string> = {
'2xs': 'h-5',
xs: 'h-5',
sm: 'h-7',
md: 'h-8',
lg: 'h-10'
}
function onInputFocus() {
hasFocus = true
// Opportunistic refresh if the cache is stale.
if (ws) void loadPaths(ws)
}
function onInputBlur() {
setTimeout(() => {
hasFocus = false
}, 150)
}
</script>
<div class="relative w-full">
<div class="relative w-full">
<TextInput
bind:this={inputEl}
bind:value
{size}
{error}
class={textInputClass}
inputProps={{
disabled,
type: 'text',
id,
autofocus,
autocomplete: 'off',
spellcheck: false,
placeholder: showList || ghostText ? '' : placeholder,
onkeydown: handleKeydown,
onkeyup: (e: KeyboardEvent) => {
if (e.key === 'Enter' && enterConsumed) {
e.preventDefault()
e.stopPropagation()
enterConsumed = false
return
}
onkeyup?.(e)
},
onfocus: onInputFocus,
onblur: onInputBlur
}}
/>
{#if ghostText && hasFocus && !cycleMode}
<div
class="pointer-events-none absolute inset-0 flex items-center overflow-hidden whitespace-pre font-normal text-xs text-primary {overlayPaddingX[
size
]} {overlayHeight[size]}"
aria-hidden="true"
>
<span class="invisible">{value ?? ''}</span>
<span class="text-tertiary/70">{ghostText}</span>
<span
class="ml-1.5 px-1 py-0 rounded border border-border-light text-[10px] text-tertiary bg-surface-secondary"
>
Enter
</span>
</div>
{/if}
</div>
{#if showList}
<div
class="absolute left-0 top-full z-10 mt-1 flex max-w-full flex-wrap items-center gap-1 rounded border border-border-light bg-surface px-1.5 py-1 shadow-sm"
>
<span class="text-[10px] text-tertiary mr-0.5">
{cycleMode ? 'Tab to cycle' : 'Tab'}
</span>
{#each displayedOptions as opt, i (opt.name)}
<Badge
clickable
selected={i === displayedActiveIndex}
class="font-mono transition-colors"
type="button"
tabindex={-1}
onmousedown={(e: MouseEvent) => e.preventDefault()}
onclick={() => selectOption(opt)}
>
{opt.name}/
</Badge>
{/each}
</div>
{/if}
</div>