From 9c28bbfd694a5047b4a8a9fe5cc2e54309f8f067 Mon Sep 17 00:00:00 2001 From: Diego Imbert <70353967+diegoimbert@users.noreply.github.com> Date: Wed, 20 May 2026 15:26:34 +0200 Subject: [PATCH] feat(frontend): new path component (#9017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * stash * ui nits * Fix contenteditable feedback look (duplicate typing) * fix right icon wrong position with placeholder * user editor in Path editor takes correct width * nits * nit * chore: remove assets-operator changes (moved to separate PR) These files were mistakenly included in this PR and belong in a dedicated PR ("Allow assets page to operators"). Co-Authored-By: Claude Opus 4.7 (1M context) * chore: remove sidebar assets-operator change (moved to separate PR) Co-Authored-By: Claude Opus 4.7 (1M context) * fix disabled * border nit * Fix disabled styling * Apply suggestion from @cubic-dev-ai[bot] Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * nit * Update frontend/src/lib/components/text_input/TextInput.svelte Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> * Fix disabled tabindex and aria-disabled on contenteditable Select The useContentEditable branch had an unconditional tabindex="0", keeping a disabled Select in the tab order, and was missing aria-disabled. Mirror the TextInput div branch. Co-authored-by: Diego Imbert * fix: drop obsolete hideFullPath prop from EditorHeader Path usage * invalidate autocomplete paths on deploy * nit pixel * use Badge in auto complete * nit prevent default * fix(autocomplete): don't let stale fetch clobber forced refresh A non-forced fetchWorkspacePaths() that started before invalidateWorkspacePaths() could still resolve afterward, overwrite the cache, and clear forceNextFetch — making the post-deploy refresh a no-op. Only write back from the promise that is still the current pending one, and only clear the force flag when the completing fetch was itself forced. * refactor(path): drop unreachable 'group' branch in owner-kind setter The Select only offers user/folder, so the 'group' branch was dead. Leave a short note pointing at validateName which still accepts 'group' for forward-compat. * fix(path): respect disableEditing on owner-kind selector Other path-editor controls disable on (disabled || disableEditing); the owner-kind Select only checked `disabled`, so read-only users (trigger editors with !can_write) could still toggle User/Folder and mutate the bound path. Reuse the existing nameDisabled flag. * Revert "fix(autocomplete): don't let stale fetch clobber forced refresh" This reverts commit 664997571460eddfbb1d8362f56d158df9799818. --------- Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Diego Imbert --- backend/windmill-api/openapi.yaml | 7 + backend/windmill-api/src/path_autocomplete.rs | 24 +++- .../src/lib/components/EditorHeader.svelte | 1 - .../src/lib/components/FlowBuilder.svelte | 5 + .../src/lib/components/FolderPicker.svelte | 31 ++--- frontend/src/lib/components/Path.svelte | 127 +++++++----------- .../components/PathNameAutocomplete.svelte | 37 +++-- .../src/lib/components/ResourceEditor.svelte | 4 + .../src/lib/components/ScriptBuilder.svelte | 5 + .../lib/components/SummaryPathDisplay.svelte | 2 - .../src/lib/components/VariableEditor.svelte | 4 + .../apps/editor/AppEditorHeader.svelte | 5 + .../src/lib/components/moveRenameManager.ts | 5 + .../raw_apps/RawAppEditorHeader.svelte | 5 + .../src/lib/components/select/Select.svelte | 121 ++++++++++++----- .../components/text_input/TextInput.svelte | 81 ++++++++++- 16 files changed, 306 insertions(+), 158 deletions(-) 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}