diff --git a/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json b/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json new file mode 100644 index 0000000000..2393837f07 --- /dev/null +++ b/backend/.sqlx/query-f175f0eda0dcdb26c08b743de80e73344dff5b98a33daaee144ffaccaa8a0bad.json @@ -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" +} diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 2dc5a6bea8..582f6ff12d 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -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 diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index 448df97ee9..4c40753ae5 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -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()) diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs new file mode 100644 index 0000000000..7bb1162051 --- /dev/null +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -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>, 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>, +} + +async fn list_paths( + _authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> JsonResult { + 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 = 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 })) +} diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 85ca9c4271..3aaa47ac9c 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -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 @@
/
{/if} diff --git a/frontend/src/lib/components/PathNameAutocomplete.svelte b/frontend/src/lib/components/PathNameAutocomplete.svelte new file mode 100644 index 0000000000..fa27f4dc3b --- /dev/null +++ b/frontend/src/lib/components/PathNameAutocomplete.svelte @@ -0,0 +1,362 @@ + + + + +
+
+ { + if (e.key === 'Enter' && enterConsumed) { + e.preventDefault() + e.stopPropagation() + enterConsumed = false + return + } + onkeyup?.(e) + }, + onfocus: onInputFocus, + onblur: onInputBlur + }} + /> + {#if ghostText && hasFocus && !cycleMode} + + {/if} +
+ {#if showList} +
+ + {cycleMode ? 'Tab to cycle' : 'Tab'} + + {#each displayedOptions as opt, i (opt.name)} + + {/each} +
+ {/if} +