diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 37d19b2bd7..2adbbcbcf7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -9836,6 +9836,13 @@ paths: - path_autocomplete parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: force + description: | + bypass the server-side cache and re-query the DB, refreshing the + cache. Used right after a deploy so the new path appears immediately. + in: query + schema: + type: boolean responses: "200": description: deduplicated path list, sorted lexicographically diff --git a/backend/windmill-api/src/path_autocomplete.rs b/backend/windmill-api/src/path_autocomplete.rs index 7bb1162051..56bfd62ce5 100644 --- a/backend/windmill-api/src/path_autocomplete.rs +++ b/backend/windmill-api/src/path_autocomplete.rs @@ -12,11 +12,11 @@ use std::{ }; use axum::{ - extract::{Extension, Path}, + extract::{Extension, Path, Query}, routing::get, Json, Router, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use windmill_common::error::JsonResult; use crate::db::{ApiAuthed, DB}; @@ -43,16 +43,28 @@ struct ListPathsResponse { paths: Arc>, } +#[derive(Deserialize)] +struct ListPathsQuery { + /// When true, bypass the cached entry and re-query the DB, refreshing the + /// cache. Used by clients that just mutated the workspace (e.g. a deploy) + /// and need the new path reflected immediately. + #[serde(default)] + force: bool, +} + async fn list_paths( _authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, + Query(ListPathsQuery { force }): Query, ) -> JsonResult { - if let Some((cached, cached_at)) = PATHS_CACHE.get(&w_id) { - if cached_at.elapsed() < CACHE_TTL { - return Ok(Json(ListPathsResponse { paths: cached })); + if !force { + 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); } - PATHS_CACHE.remove(&w_id); } let mut paths: Vec = sqlx::query_scalar!( diff --git a/frontend/src/lib/components/EditorHeader.svelte b/frontend/src/lib/components/EditorHeader.svelte index e52210bb06..0e107593ea 100644 --- a/frontend/src/lib/components/EditorHeader.svelte +++ b/frontend/src/lib/components/EditorHeader.svelte @@ -204,7 +204,6 @@ initialPath={snapshotPath ?? path ?? ''} namePlaceholder={kind} {kind} - hideFullPath size="sm" drawerOffset={4000} /> diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index df340815e2..3558e5020e 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -77,6 +77,7 @@ import { type TriggerContext, type ScheduleTrigger } from './triggers' import type { SavedAndModifiedValue } from './common/confirmationModal/unsavedTypes' import DeployButton from './DeployButton.svelte' + import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte' import type { FlowWithDraftAndDraftTriggers, Trigger } from './triggers/utils' import { deployTriggers, @@ -566,6 +567,10 @@ }) } + // New/updated path now exists server-side — drop the autocomplete + // cache so it shows up immediately instead of after the 60s TTL. + invalidateWorkspacePaths($workspaceStore!) + const { draft_triggers: _, ...newSavedFlow } = flowStore.val as OpenFlow & { draft_triggers: Trigger[] } diff --git a/frontend/src/lib/components/FolderPicker.svelte b/frontend/src/lib/components/FolderPicker.svelte index 206e02f825..beb8300136 100644 --- a/frontend/src/lib/components/FolderPicker.svelte +++ b/frontend/src/lib/components/FolderPicker.svelte @@ -1,7 +1,7 @@
-
+
{#if meta != undefined} + {@const nameDisabled = disabled || disableEditing} {#if !hideUser}
- { - setDirty() - const kind = e.detail - if (meta) { - if (kind === 'folder') { + { - currentTarget.select() - }} - /> - -
-
- {/if} - {#if pathUsageInFlowsPromise || pathUsageInAppsPromise || pathUsageInScriptsPromise} @@ -618,9 +599,3 @@ {/if}
- - diff --git a/frontend/src/lib/components/PathNameAutocomplete.svelte b/frontend/src/lib/components/PathNameAutocomplete.svelte index fa27f4dc3b..62e8d141db 100644 --- a/frontend/src/lib/components/PathNameAutocomplete.svelte +++ b/frontend/src/lib/components/PathNameAutocomplete.svelte @@ -19,18 +19,31 @@ * the same page reuse a single fetch per workspace. */ const pathListCache = new Map() + /** 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() + export async function fetchWorkspacePaths(workspace: string): Promise { + const force = forceNextFetch.has(workspace) const now = Date.now() const existing = pathListCache.get(workspace) - if (existing) { + // 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 }) + 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) @@ -43,6 +56,7 @@ export function invalidateWorkspacePaths(workspace: string) { pathListCache.delete(workspace) + forceNextFetch.add(workspace) } /** Derive the set of path segments that exist directly under a given folder @@ -72,6 +86,7 @@ @@ -171,39 +184,79 @@ {/if} - (open ? filterText : inputText), (v) => open && (filterText = v)} - placeholder={loading && !value - ? 'Loading...' - : value && !showPlaceholderOnOpen - ? inputText - : placeholder} - style={containerStyle} - class={twMerge( - inputBaseClass, - inputSizeClasses[size], - ButtonType.UnifiedHeightClasses[size], - inputBorderClass({ error, forceFocus: open }), - 'w-full', - open ? '' : 'cursor-pointer', - // Show value as placeholder when opening the dropdown and the search is empty - !value ? 'placeholder-hint' : '!placeholder-primary', - (clearable || RightIcon) && !disabled && value ? 'pr-8' : '', - inputClass ?? '' - )} - autocomplete="off" - oninput={(e) => { - // Explicitly open dropdown if closed and update filterText - if (!open) open = true - filterText = e.currentTarget.value - }} - onpointerdown={() => (open = true)} - bind:this={inputEl} - {id} - /> + {#if useContentEditable} + {@const placeholderText = + loading && !value ? 'Loading...' : value && !showPlaceholderOnOpen ? inputText : placeholder} +
{ + if (!open) open = true + filterText = e.currentTarget.textContent ?? '' + }} + onpointerdown={() => !disabled && (open = true)} + onkeydown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + } + }} + bind:this={inputEl} + >
+ {:else} + (open ? filterText : inputText), (v) => open && (filterText = v)} + placeholder={loading && !value + ? 'Loading...' + : value && !showPlaceholderOnOpen + ? inputText + : placeholder} + style={containerStyle} + class={twMerge( + inputBaseClass, + inputSizeClasses[size], + ButtonType.UnifiedHeightClasses[size], + inputBorderClass({ error, forceFocus: open }), + 'w-full', + open ? '' : 'cursor-pointer', + // Show value as placeholder when opening the dropdown and the search is empty + !value ? 'placeholder-hint' : '!placeholder-primary', + loading || (clearable && !disabled && value) || RightIcon ? 'pr-8' : '', + inputClass ?? '' + )} + autocomplete="off" + oninput={(e) => { + // Explicitly open dropdown if closed and update filterText + if (!open) open = true + filterText = e.currentTarget.value + }} + onpointerdown={() => !disabled && (open = true)} + bind:this={inputEl} + {id} + /> + {/if} does. + // + // In "large mode" (viewport ≥ 1760px, where app.css bumps :root to 18px → + // font 13.5px) headless-Chromium ink measurement showed the text sitting + // ~1px low with the base leading. The residual is a fixed ~2px of line box, + // so the exact centered value there is (content-box height − 2px). Scoped to + // the same 1760px breakpoint as the font-size bump; small mode is unchanged. + export const inputLeadingClasses: Record = { + xs: 'leading-4 min-[1760px]:leading-[calc(1rem_-_2px)]', // h-5 − py-0.5 → 1rem + sm: 'leading-6 min-[1760px]:leading-[calc(1.5rem_-_2px)]', // h-7 − py-0.5 → 1.5rem + md: 'leading-8 min-[1760px]:leading-[calc(2rem_-_2px)]', // h-8, no py → 2rem + lg: 'leading-10 min-[1760px]:leading-[calc(2.5rem_-_2px)]' // h-10, no py → 2.5rem + } - {#if underlyingInputEl === 'textarea'} @@ -108,4 +156,25 @@ bind:this={inputEl} bind:value /> +{:else if underlyingInputEl === 'div'} + {@const { disabled, placeholder, ...divProps } = (inputProps ?? {}) as DivInputProps} + +
{ + if (e.key === 'Enter') e.preventDefault() + divProps?.onkeydown?.(e) + }} + class={fullClassName} + data-placeholder={placeholder ?? ''} + onpointerdown={(e) => e.stopImmediatePropagation()} + oninput={(e) => { + value = e.currentTarget.textContent ?? '' + }} + bind:this={inputEl} + >
{/if}