feat: add path name autocomplete with ghost text and folder cycling (#8731)

* feat: add path name autocomplete with ghost text and folder cycling

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

* fix: filter out archived/deleted/draft paths from autocomplete

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

* feat: show subfolders immediately after Tab-navigating into a folder

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

* feat: remove 2-char minimum for suggestions, hide placeholder when suggestions show

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

* feat: show LCP ghost text for multiple matches, Enter accepts it for Tab cycling

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

* fix: suppress Path.svelte Enter dispatch when ghost text is accepted

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

* fix: compute LCP inline in Enter handler to avoid reactive timing issues

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

* fix: Enter picks the first folder and navigates into it

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

* fix: Enter picks the currently Tab-highlighted folder, not always the first

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

* style: remove stray blank lines in applyCycleOrComplete

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

* fix: review fixes — $bindable default, openapi cache description, non-null assertion

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

* chore: add sqlx query cache for path_autocomplete

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-05 23:44:21 -04:00
committed by GitHub
parent c721fac466
commit e32662169a
6 changed files with 513 additions and 11 deletions
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT path AS \"path!\" FROM (\n (SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000)\n UNION\n (SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000)\n UNION\n (SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)\n UNION\n (SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)\n ) t\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad"
}
+29
View File
@@ -8777,6 +8777,35 @@ paths:
items:
$ref: "#/components/schemas/FlowConversationMessage"
/w/{workspace}/path_autocomplete/list_paths:
get:
summary: list all paths in a workspace for client-side autocomplete
description: |
Returns the flat list of all item paths visible to the caller across
scripts, flows, apps, raw apps, variables, and resources. Intended to
feed an entirely client-side path autocomplete UI: the frontend fetches
once (server caches per workspace for 60s) and performs all prefix/segment
computation locally. Capped at 20,000 paths (5,000 per table).
operationId: listPathAutocompletePaths
tags:
- path_autocomplete
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: deduplicated path list, sorted lexicographically
content:
application/json:
schema:
type: object
properties:
paths:
type: array
items:
type: string
required:
- paths
/w/{workspace}/raw_apps/list:
get:
summary: list all raw apps
+5
View File
@@ -131,6 +131,7 @@ pub mod oauth2_oss;
#[cfg(feature = "private")]
pub mod oidc_ee;
mod oidc_oss;
mod path_autocomplete;
mod raw_apps;
mod resources;
#[cfg(feature = "private")]
@@ -601,6 +602,10 @@ pub async fn run_server(
})
.nest("/ai", ai::workspaced_service())
.nest("/npm_proxy", windmill_api_npm_proxy::workspaced_service())
.nest(
"/path_autocomplete",
path_autocomplete::workspaced_service(),
)
.nest("/raw_apps", raw_apps::workspaced_service())
.nest("/resources", resources::workspaced_service())
.nest("/schedules", windmill_api_schedule::workspaced_service())
@@ -0,0 +1,86 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
use std::{
sync::{Arc, LazyLock},
time::{Duration, Instant},
};
use axum::{
extract::{Extension, Path},
routing::get,
Json, Router,
};
use serde::Serialize;
use windmill_common::error::JsonResult;
use crate::db::{ApiAuthed, DB};
// Per-table row cap is inlined into the SQL as `LIMIT 5000`.
// With 6 tables, the absolute ceiling is ~30k paths pre-dedup.
/// Final cap applied after dedup/sort.
const MAX_PATHS: usize = 20_000;
/// TTL for the per-workspace path list cache.
const CACHE_TTL: Duration = Duration::from_secs(60);
/// Workspace-wide path list cache keyed by workspace_id only.
/// One entry per workspace shared across all users — autocomplete is a
/// navigation hint, not an access gate. Saves memory and warms faster.
static PATHS_CACHE: LazyLock<quick_cache::sync::Cache<String, (Arc<Vec<String>>, Instant)>> =
LazyLock::new(|| quick_cache::sync::Cache::new(500));
pub fn workspaced_service() -> Router {
Router::new().route("/list_paths", get(list_paths))
}
#[derive(Serialize)]
struct ListPathsResponse {
paths: Arc<Vec<String>>,
}
async fn list_paths(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<ListPathsResponse> {
if let Some((cached, cached_at)) = PATHS_CACHE.get(&w_id) {
if cached_at.elapsed() < CACHE_TTL {
return Ok(Json(ListPathsResponse { paths: cached }));
}
PATHS_CACHE.remove(&w_id);
}
let mut paths: Vec<String> = sqlx::query_scalar!(
r#"
SELECT path AS "path!" FROM (
(SELECT DISTINCT path FROM script WHERE workspace_id = $1 AND archived = false AND deleted = false AND draft_only IS NOT true LIMIT 5000)
UNION
(SELECT path FROM flow WHERE workspace_id = $1 AND archived = false AND draft_only IS NOT true LIMIT 5000)
UNION
(SELECT path FROM app WHERE workspace_id = $1 LIMIT 5000)
UNION
(SELECT path FROM raw_app WHERE workspace_id = $1 LIMIT 5000)
UNION
(SELECT path FROM variable WHERE workspace_id = $1 LIMIT 5000)
UNION
(SELECT path FROM resource WHERE workspace_id = $1 LIMIT 5000)
) t
"#,
&w_id,
)
.fetch_all(&db)
.await?;
paths.sort_unstable();
paths.truncate(MAX_PATHS);
let paths = Arc::new(paths);
PATHS_CACHE.insert(w_id, (paths.clone(), Instant::now()));
Ok(Json(ListPathsResponse { paths }))
}
+9 -11
View File
@@ -36,6 +36,7 @@
import Tooltip from './Tooltip.svelte'
import { tick } from 'svelte'
import FolderPicker from './FolderPicker.svelte'
import PathNameAutocomplete from './PathNameAutocomplete.svelte'
import TextInput from './text_input/TextInput.svelte'
type PathKind =
@@ -101,7 +102,7 @@
}
})
let inputP: TextInput | undefined = $state(undefined)
let inputP: PathNameAutocomplete | undefined = $state(undefined)
const dispatch = createEventDispatcher()
@@ -479,20 +480,17 @@
<div class="text-sm text-secondary">/</div>
<label class="block grow min-w-32 max-w-md">
<!-- svelte-ignore a11y_autofocus -->
<TextInput
<PathNameAutocomplete
bind:this={inputP}
bind:value={meta.name}
prefix={`${meta.ownerKind?.charAt(0) ?? ''}/${meta.owner ?? ''}/`}
{size}
{error}
inputProps={{
disabled: disabled || disableEditing,
type: 'text',
id: 'path',
autofocus,
autocomplete: 'off',
onkeyup: handleKeyUp,
placeholder: namePlaceholder
}}
{autofocus}
id="path"
placeholder={namePlaceholder}
disabled={disabled || disableEditing}
onkeyup={handleKeyUp}
/>
</label>
{/if}
@@ -0,0 +1,362 @@
<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>()
export async function fetchWorkspacePaths(workspace: string): Promise<string[]> {
const now = Date.now()
const existing = pathListCache.get(workspace)
if (existing) {
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 })
const paths = res.paths ?? []
pathListCache.set(workspace, { at: Date.now(), paths, pending: null })
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)
}
/** 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 { 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
onkeyup?: (e: KeyboardEvent) => void
}
let {
prefix,
value = $bindable(),
placeholder = '',
disabled = false,
autofocus = false,
id,
size = 'md',
error,
onkeyup
}: Props = $props()
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 ($workspaceStore === workspace) allPaths = paths
}
$effect(() => {
const ws = $workspaceStore
if (ws) void loadPaths(ws)
})
$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> = {
xs: 'px-1',
sm: 'px-2',
md: 'px-2',
lg: 'px-2'
}
const overlayHeight: Record<InputSize, string> = {
xs: 'h-5',
sm: 'h-7',
md: 'h-8',
lg: 'h-10'
}
function onInputFocus() {
hasFocus = true
// Opportunistic refresh if the cache is stale.
if ($workspaceStore) void loadPaths($workspaceStore)
}
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}
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)}
<button
type="button"
tabindex="-1"
onmousedown={(e) => e.preventDefault()}
onclick={() => selectOption(opt)}
class="px-1.5 py-0 rounded border text-[11px] font-mono leading-5 transition-colors
{i === displayedActiveIndex
? 'border-border-selected bg-surface-selected text-primary'
: 'border-border-light bg-surface-secondary text-secondary hover:border-border-selected'}"
>
{opt.name}/
</button>
{/each}
</div>
{/if}
</div>