mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 08:02:19 +00:00
feat(frontend): new path component (#9017)
* 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) <noreply@anthropic.com>
* chore: remove sidebar assets-operator change (moved to separate PR)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* 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 <diegoimbert@users.noreply.github.com>
* 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 6649975714.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 <diegoimbert@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(ListPathsQuery { force }): Query<ListPathsQuery>,
|
||||
) -> 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 }));
|
||||
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<String> = sqlx::query_scalar!(
|
||||
|
||||
@@ -204,7 +204,6 @@
|
||||
initialPath={snapshotPath ?? path ?? ''}
|
||||
namePlaceholder={kind}
|
||||
{kind}
|
||||
hideFullPath
|
||||
size="sm"
|
||||
drawerOffset={4000}
|
||||
/>
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { FolderService } from '$lib/gen'
|
||||
import { workspaceStore, userStore } from '$lib/stores'
|
||||
import { Pen, PlusIcon } from 'lucide-svelte'
|
||||
import { ChevronDown, Pen, PlusIcon } from 'lucide-svelte'
|
||||
import { Button, Drawer, DrawerContent } from './common'
|
||||
import FolderEditor from './FolderEditor.svelte'
|
||||
import Select from './select/Select.svelte'
|
||||
@@ -32,6 +32,7 @@
|
||||
disableEditing?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
drawerOffset?: number
|
||||
selectInputClass?: string
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -40,11 +41,10 @@
|
||||
disabled = $bindable(undefined),
|
||||
disableEditing = $bindable(undefined),
|
||||
size = 'md',
|
||||
drawerOffset = 0
|
||||
drawerOffset = 0,
|
||||
selectInputClass
|
||||
}: Props = $props()
|
||||
|
||||
let hovering = $state(false)
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
loadingFolders = true
|
||||
try {
|
||||
@@ -198,13 +198,12 @@
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="flex flex-row w-full items-center relative"
|
||||
class="flex group flex-row w-full items-center relative"
|
||||
role="group"
|
||||
onkeydown={handleSelectKeydown}
|
||||
onmouseenter={() => (hovering = true)}
|
||||
onmouseleave={() => (hovering = false)}
|
||||
>
|
||||
<Select
|
||||
useContentEditable
|
||||
bind:value={folderName}
|
||||
bind:filterText
|
||||
bind:open={selectOpen}
|
||||
@@ -214,6 +213,8 @@
|
||||
{size}
|
||||
placeholder="Select folder"
|
||||
class="grow min-w-0"
|
||||
inputClass={selectInputClass}
|
||||
RightIcon={ChevronDown}
|
||||
>
|
||||
{#snippet endSnippet({ item, close })}
|
||||
<Button
|
||||
@@ -246,20 +247,4 @@
|
||||
</button>
|
||||
{/snippet}
|
||||
</Select>
|
||||
{#if folderName && hovering && !loadingFolders && !disabled && !disableEditing}
|
||||
<div class="absolute right-2 z-20">
|
||||
<Button
|
||||
variant="subtle"
|
||||
unifiedSize="xs"
|
||||
wrapperClasses="pl-1"
|
||||
btnClasses="hover:bg-surface-tertiary"
|
||||
onClick={() => {
|
||||
editingFolder = folderName
|
||||
viewFolder?.openDrawer()
|
||||
}}
|
||||
startIcon={{ icon: Pen }}
|
||||
iconOnly
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -29,16 +29,19 @@
|
||||
import { createEventDispatcher, getContext, untrack } from 'svelte'
|
||||
import { writable } from 'svelte/store'
|
||||
import { Alert, Button } from './common'
|
||||
import Badge from './common/badge/Badge.svelte'
|
||||
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import { random_adj } from './random_positive_adjetive'
|
||||
import { Folder, SearchCode, User } from 'lucide-svelte'
|
||||
import { ChevronDown, SearchCode } from 'lucide-svelte'
|
||||
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'
|
||||
import TextInput, {
|
||||
inputBaseClass,
|
||||
inputBorderClass,
|
||||
inputSizeClasses
|
||||
} from './text_input/TextInput.svelte'
|
||||
import Select from './select/Select.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import InputError from './InputError.svelte'
|
||||
|
||||
type PathKind =
|
||||
@@ -73,7 +76,6 @@
|
||||
kind: PathKind
|
||||
hideUser?: boolean
|
||||
disableEditing?: boolean
|
||||
hideFullPath?: boolean
|
||||
size?: 'sm' | 'md'
|
||||
drawerOffset?: number
|
||||
}
|
||||
@@ -91,7 +93,6 @@
|
||||
kind,
|
||||
hideUser = false,
|
||||
disableEditing = false,
|
||||
hideFullPath = false,
|
||||
size = 'md',
|
||||
drawerOffset = 0
|
||||
}: Props = $props()
|
||||
@@ -422,48 +423,46 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div class="flex gap-2 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center">
|
||||
<div
|
||||
class={twMerge(
|
||||
inputBaseClass,
|
||||
inputBorderClass({ error: !!error }),
|
||||
inputSizeClasses[size],
|
||||
'flex gap-0 pb-0 mb-1 flex-col flex-wrap sm:flex-row sm:items-center',
|
||||
disabled && '!bg-surface-disabled cursor-not-allowed border-none'
|
||||
)}
|
||||
>
|
||||
{#if meta != undefined}
|
||||
{@const nameDisabled = disabled || disableEditing}
|
||||
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||
{#if !hideUser}
|
||||
<div class="block">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={meta.ownerKind}
|
||||
on:selected={(e) => {
|
||||
setDirty()
|
||||
const kind = e.detail
|
||||
if (meta) {
|
||||
if (kind === 'folder') {
|
||||
<Select
|
||||
items={[
|
||||
{ value: 'user', label: 'User' },
|
||||
{ value: 'folder', label: 'Folder' }
|
||||
]}
|
||||
RightIcon={ChevronDown}
|
||||
transformInputSelectedText={(t) => t.substring(0, 1).toLowerCase()}
|
||||
inputClass={twMerge('border-none', disabled && '!bg-transparent')}
|
||||
useContentEditable
|
||||
bind:value={
|
||||
() => meta?.ownerKind,
|
||||
(v) => {
|
||||
if (!meta || !v) return
|
||||
setDirty()
|
||||
meta.ownerKind = v
|
||||
if (v === 'folder') {
|
||||
meta.owner = folders?.[0]?.name ?? ''
|
||||
} else if (kind === 'group') {
|
||||
meta.owner = 'all'
|
||||
} else {
|
||||
// 'group' is unreachable here (Select only offers user/folder)
|
||||
// but validateName still accepts it for forward-compat.
|
||||
meta.owner = $userStore?.username?.split('@')[0] ?? ''
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled || disableEditing}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton
|
||||
icon={User}
|
||||
disabled={disabled || disableEditing}
|
||||
value="user"
|
||||
label="User"
|
||||
{size}
|
||||
{item}
|
||||
/>
|
||||
<!-- <ToggleButton light size="xs" value="group" position="center">Group</ToggleButton> -->
|
||||
<ToggleButton
|
||||
icon={Folder}
|
||||
disabled={disabled || disableEditing}
|
||||
value="folder"
|
||||
label="Folder"
|
||||
{size}
|
||||
{item}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
}
|
||||
disabled={nameDisabled}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !hideUser}
|
||||
@@ -471,17 +470,18 @@
|
||||
{/if}
|
||||
<div>
|
||||
{#if meta.ownerKind === 'user'}
|
||||
{@const userOwnerDisabled =
|
||||
disabled || !($superadmin || ($userStore?.is_admin ?? false)) || disableEditing}
|
||||
<label class="block shrink min-w-0">
|
||||
<TextInput
|
||||
class="!w-36"
|
||||
class={twMerge('!border-none', userOwnerDisabled && '!bg-transparent')}
|
||||
{size}
|
||||
underlyingInputEl="div"
|
||||
bind:value={meta.owner}
|
||||
inputProps={{
|
||||
type: 'text',
|
||||
placeholder: $userStore?.username ?? '',
|
||||
onkeydown: setDirty,
|
||||
disabled:
|
||||
disabled || !($superadmin || ($userStore?.is_admin ?? false)) || disableEditing
|
||||
disabled: userOwnerDisabled
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -494,12 +494,13 @@
|
||||
{disableEditing}
|
||||
{size}
|
||||
{drawerOffset}
|
||||
selectInputClass={twMerge('!border-none', disabled && '!bg-transparent')}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-secondary">/</div>
|
||||
<label class="block grow min-w-32 max-w-md">
|
||||
<label class="block grow min-w-32">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<PathNameAutocomplete
|
||||
bind:this={inputP}
|
||||
@@ -510,37 +511,17 @@
|
||||
{autofocus}
|
||||
id="path"
|
||||
placeholder={namePlaceholder}
|
||||
disabled={disabled || disableEditing}
|
||||
disabled={nameDisabled}
|
||||
onkeyup={handleKeyUp}
|
||||
textInputClass={twMerge(
|
||||
'border-none',
|
||||
nameDisabled && '!bg-transparent disabled:!bg-transparent'
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !hideFullPath}
|
||||
<div class="flex flex-col w-full mt-2">
|
||||
<div class="flex justify-start w-full">
|
||||
<Badge
|
||||
color="gray"
|
||||
class="center-center !bg-surface-secondary !text-primary !w-[70px] !h-[24px] rounded-r-none border"
|
||||
>
|
||||
Full path
|
||||
</Badge>
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={path}
|
||||
size={path?.length || 50}
|
||||
class="font-mono !text-xs max-w-[calc(100%-70px)] !w-auto !h-[24px] !py-0 !border-l-0 !rounded-l-none"
|
||||
onfocus={({ currentTarget }) => {
|
||||
currentTarget.select()
|
||||
}}
|
||||
/>
|
||||
<!-- <span class="font-mono text-sm break-all">{path}</span> -->
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<InputError {error} />
|
||||
|
||||
{#if pathUsageInFlowsPromise || pathUsageInAppsPromise || pathUsageInScriptsPromise}
|
||||
@@ -618,9 +599,3 @@
|
||||
</Alert>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
input:disabled {
|
||||
background: rgba(200, 200, 200, 0.267);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,18 +19,31 @@
|
||||
* 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)
|
||||
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 @@
|
||||
|
||||
<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'
|
||||
|
||||
@@ -84,6 +99,7 @@
|
||||
id?: string
|
||||
size?: InputSize
|
||||
error?: string | boolean
|
||||
textInputClass?: string
|
||||
onkeyup?: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
@@ -96,6 +112,7 @@
|
||||
id,
|
||||
size = 'md',
|
||||
error,
|
||||
textInputClass,
|
||||
onkeyup
|
||||
}: Props = $props()
|
||||
|
||||
@@ -297,6 +314,7 @@
|
||||
bind:value
|
||||
{size}
|
||||
{error}
|
||||
class={textInputClass}
|
||||
inputProps={{
|
||||
disabled,
|
||||
type: 'text',
|
||||
@@ -344,18 +362,17 @@
|
||||
{cycleMode ? 'Tab to cycle' : 'Tab'}
|
||||
</span>
|
||||
{#each displayedOptions as opt, i (opt.name)}
|
||||
<button
|
||||
<Badge
|
||||
clickable
|
||||
selected={i === displayedActiveIndex}
|
||||
class="font-mono transition-colors"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
tabindex={-1}
|
||||
onmousedown={(e: MouseEvent) => 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>
|
||||
</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte'
|
||||
import ResourceForm from './ResourceForm.svelte'
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
import Alert from './common/alert/Alert.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
@@ -246,6 +247,9 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
// Path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths(ws)
|
||||
}
|
||||
sendUserToast(
|
||||
dirty.length > 1 ? `Saved resource in ${dirty.length} workspaces` : `Saved resource`
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
type Value
|
||||
} from '$lib/utils'
|
||||
import Path from './Path.svelte'
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
import ScriptEditor from './ScriptEditor.svelte'
|
||||
import { Alert, Button, Drawer, SecondsInput, Tab, TabContent, Tabs } from './common'
|
||||
import LanguageIcon from './common/languageIcons/LanguageIcon.svelte'
|
||||
@@ -626,6 +627,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!)
|
||||
|
||||
if (!initialPath) {
|
||||
await CaptureService.moveCapturesAndConfigs({
|
||||
workspace: $workspaceStore!,
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
initialPath={path ?? ''}
|
||||
namePlaceholder={kind}
|
||||
{kind}
|
||||
hideFullPath
|
||||
size="sm"
|
||||
drawerOffset={4000}
|
||||
/>
|
||||
@@ -199,7 +198,6 @@
|
||||
initialPath={path ?? ''}
|
||||
namePlaceholder={kind}
|
||||
{kind}
|
||||
hideFullPath
|
||||
size="sm"
|
||||
drawerOffset={4000}
|
||||
/>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import { canWrite } from '$lib/utils'
|
||||
import { Save } from 'lucide-svelte'
|
||||
import VariableForm from './VariableForm.svelte'
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
import WsSpecificVersions from './WsSpecificVersions.svelte'
|
||||
import { resource } from 'runed'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
@@ -186,6 +187,9 @@
|
||||
}
|
||||
})
|
||||
}
|
||||
// Path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths(ws)
|
||||
}
|
||||
sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`)
|
||||
dispatch('create')
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
|
||||
import EditorHeader from '$lib/components/EditorHeader.svelte'
|
||||
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
||||
import { goto } from '$app/navigation'
|
||||
import HideButton from './settingsPanel/HideButton.svelte'
|
||||
import DeployOverrideConfirmationModal from '$lib/components/common/confirmationModal/DeployOverrideConfirmationModal.svelte'
|
||||
@@ -215,6 +216,9 @@
|
||||
preserve_on_behalf_of: preserveOnBehalfOf || undefined
|
||||
}
|
||||
})
|
||||
// New path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths($workspaceStore!)
|
||||
savedApp = {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
@@ -313,6 +317,7 @@
|
||||
}
|
||||
})
|
||||
invalidatePicker($workspaceStore!, 'app')
|
||||
invalidateWorkspacePaths($workspaceStore!)
|
||||
savedApp = {
|
||||
summary: $summary,
|
||||
value: structuredClone($state.snapshot($app)),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { AppService, FlowService, ScriptService } from '$lib/gen'
|
||||
import { invalidateWorkspacePaths } from './PathNameAutocomplete.svelte'
|
||||
|
||||
type ItemKind = 'flow' | 'script' | 'app'
|
||||
|
||||
@@ -74,4 +75,8 @@ export async function updateItemPathAndSummary(opts: {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The path changed (rename/move) — drop the autocomplete cache so the new
|
||||
// path shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths(workspace)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Button from '$lib/components/common/button/Button.svelte'
|
||||
import { isMac, userPathPrefix } from '$lib/utils'
|
||||
import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker'
|
||||
import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte'
|
||||
|
||||
import { AppService, DraftService, type Policy } from '$lib/gen'
|
||||
import { rawAppToHubUrl } from '$lib/hub'
|
||||
@@ -253,6 +254,9 @@
|
||||
css
|
||||
}
|
||||
})
|
||||
// New path now exists server-side — drop the autocomplete cache so
|
||||
// it shows up immediately instead of after the 60s TTL.
|
||||
invalidateWorkspacePaths($workspaceStore!)
|
||||
savedApp = {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
@@ -362,6 +366,7 @@
|
||||
}
|
||||
})
|
||||
invalidatePicker($workspaceStore!, 'app')
|
||||
invalidateWorkspacePaths($workspaceStore!)
|
||||
savedApp = {
|
||||
summary: summary,
|
||||
value: structuredClone(stateSnapshot(app)),
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import {
|
||||
inputBaseClass,
|
||||
inputBorderClass,
|
||||
inputLeadingClasses,
|
||||
inputSizeClasses
|
||||
} from '../text_input/TextInput.svelte'
|
||||
import { ButtonType } from '../common/button/model'
|
||||
@@ -53,6 +54,7 @@
|
||||
onBlur,
|
||||
onClear,
|
||||
onCreateItem,
|
||||
useContentEditable = false,
|
||||
startSnippet,
|
||||
endSnippet,
|
||||
bottomSnippet
|
||||
@@ -89,6 +91,7 @@
|
||||
onBlur?: () => void
|
||||
onClear?: () => void
|
||||
onCreateItem?: (value: string) => void
|
||||
useContentEditable?: boolean
|
||||
startSnippet?: Snippet<[{ item: ProcessedItem<Value>; close: () => void }]>
|
||||
endSnippet?: Snippet<[{ item: ProcessedItem<Value>; close: () => void }]>
|
||||
bottomSnippet?: Snippet<[{ close: () => void }]>
|
||||
@@ -97,7 +100,7 @@
|
||||
let disabled = $derived(_disabled || (loading && !value))
|
||||
let iconSize = $derived(ButtonType.UnifiedIconSizes[size])
|
||||
|
||||
let inputEl: HTMLInputElement | undefined = $state()
|
||||
let inputEl: HTMLInputElement | HTMLDivElement | undefined = $state()
|
||||
|
||||
let processedItems: ProcessedItem<Value>[] = $derived.by(() => {
|
||||
let args = { items, createText, filterText, groupBy, onCreateItem, sortBy }
|
||||
@@ -135,6 +138,16 @@
|
||||
let text = valueEntry?.label ?? getLabel({ value }) ?? ''
|
||||
return transformInputSelectedText?.(text, value) ?? text
|
||||
})
|
||||
|
||||
// contenteditable is owned by the browser while typing, so the text node
|
||||
// must be set imperatively — `>{expr}</div>` would race with the browser's
|
||||
// own DOM mutations and produce duplicated text.
|
||||
$effect(() => {
|
||||
const target = open ? filterText : inputText
|
||||
if (useContentEditable && inputEl && inputEl.textContent !== target) {
|
||||
inputEl.textContent = target
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -171,39 +184,79 @@
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
{autofocus}
|
||||
{disabled}
|
||||
type="text"
|
||||
bind:value={() => (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}
|
||||
<div
|
||||
contenteditable={!disabled}
|
||||
role="textbox"
|
||||
aria-disabled={disabled}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
{id}
|
||||
style={containerStyle}
|
||||
class={twMerge(
|
||||
inputBaseClass,
|
||||
inputSizeClasses[size],
|
||||
ButtonType.UnifiedHeightClasses[size],
|
||||
inputBorderClass({ error, forceFocus: open }),
|
||||
'w-full whitespace-pre overflow-hidden',
|
||||
inputLeadingClasses[size],
|
||||
'focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0',
|
||||
open ? '' : 'cursor-pointer',
|
||||
loading || (clearable && !disabled && value) || RightIcon ? 'pr-7' : '',
|
||||
'empty:before:content-[attr(data-placeholder)]',
|
||||
!value ? 'empty:before:text-hint' : 'empty:before:text-primary',
|
||||
disabled && '!bg-surface-disabled !border-transparent !text-disabled pointer-events-none',
|
||||
inputClass ?? ''
|
||||
)}
|
||||
data-placeholder={placeholderText}
|
||||
oninput={(e) => {
|
||||
if (!open) open = true
|
||||
filterText = e.currentTarget.textContent ?? ''
|
||||
}}
|
||||
onpointerdown={() => !disabled && (open = true)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
}
|
||||
}}
|
||||
bind:this={inputEl}
|
||||
></div>
|
||||
{:else}
|
||||
<input
|
||||
{autofocus}
|
||||
{disabled}
|
||||
type="text"
|
||||
bind:value={() => (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}
|
||||
<SelectDropdown
|
||||
class={dropdownClass}
|
||||
{disablePortal}
|
||||
|
||||
@@ -37,14 +37,38 @@
|
||||
md: twMerge(ButtonType.UnifiedSizingClasses.md, ButtonType.UnifiedMinHeightClasses.md, 'px-2'),
|
||||
lg: twMerge(ButtonType.UnifiedSizingClasses.lg, ButtonType.UnifiedMinHeightClasses.lg, 'px-2')
|
||||
}
|
||||
|
||||
// Base leading == (unified height − vertical padding) so a single-line
|
||||
// contenteditable div centers its text the way a native <input> 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<ButtonType.UnifiedSize, string> = {
|
||||
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
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts" generics="UnderlyingInputElT extends 'input' | 'textarea' = 'input'">
|
||||
import type { HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
|
||||
<script lang="ts" generics="UnderlyingInputElT extends 'input' | 'textarea' | 'div' = 'input'">
|
||||
import type { HTMLAttributes, HTMLInputAttributes, HTMLTextareaAttributes } from 'svelte/elements'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
type Props<UnderlyingInputElT extends 'input' | 'textarea'> = {
|
||||
inputProps?: UnderlyingInputElT extends 'input' ? HTMLInputAttributes : HTMLTextareaAttributes
|
||||
type DivInputProps = HTMLAttributes<HTMLDivElement> & {
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
type Props<UnderlyingInputElT extends 'input' | 'textarea' | 'div'> = {
|
||||
inputProps?: UnderlyingInputElT extends 'input'
|
||||
? HTMLInputAttributes
|
||||
: UnderlyingInputElT extends 'textarea'
|
||||
? HTMLTextareaAttributes
|
||||
: DivInputProps
|
||||
value?: string | number
|
||||
class?: string
|
||||
error?: string | boolean
|
||||
@@ -58,10 +82,18 @@
|
||||
}
|
||||
|
||||
export function select() {
|
||||
inputEl?.select()
|
||||
if (inputEl instanceof HTMLDivElement) {
|
||||
const range = document.createRange()
|
||||
range.selectNodeContents(inputEl)
|
||||
const sel = window.getSelection()
|
||||
sel?.removeAllRanges()
|
||||
sel?.addRange(range)
|
||||
} else {
|
||||
inputEl?.select()
|
||||
}
|
||||
}
|
||||
|
||||
let inputEl: HTMLInputElement | HTMLTextAreaElement | undefined = $state()
|
||||
let inputEl: HTMLInputElement | HTMLTextAreaElement | HTMLDivElement | undefined = $state()
|
||||
|
||||
let {
|
||||
inputProps: _inputProps,
|
||||
@@ -75,6 +107,8 @@
|
||||
|
||||
let underlyingInputEl = $derived(_underlyingInputEl ?? ('input' as const))
|
||||
let inputProps = $derived(_inputProps as any)
|
||||
let isDiv = $derived(underlyingInputEl === 'div')
|
||||
let divDisabled = $derived(isDiv && Boolean(inputProps?.disabled))
|
||||
|
||||
let fullClassName = $derived(
|
||||
twMerge(
|
||||
@@ -84,9 +118,23 @@
|
||||
inputBorderClass({ error: !!error }),
|
||||
unifiedHeight ? ButtonType.UnifiedHeightClasses[size] : '',
|
||||
'w-full',
|
||||
isDiv &&
|
||||
`whitespace-pre overflow-hidden ${inputLeadingClasses[size]} focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 empty:before:content-[attr(data-placeholder)] empty:before:text-hint`,
|
||||
divDisabled && '!bg-surface-disabled !border-transparent !text-disabled cursor-not-allowed',
|
||||
className
|
||||
)
|
||||
)
|
||||
|
||||
// contenteditable is mutated by the browser as the user types — a templated
|
||||
// `>{value}</div>` would race with that and produce duplicated text. Sync
|
||||
// imperatively, with an equality guard so user input doesn't echo back.
|
||||
$effect(() => {
|
||||
if (underlyingInputEl !== 'div' || !inputEl) return
|
||||
const target = value == null ? '' : String(value)
|
||||
if (inputEl.textContent !== target) {
|
||||
inputEl.textContent = target
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if underlyingInputEl === 'textarea'}
|
||||
@@ -108,4 +156,25 @@
|
||||
bind:this={inputEl}
|
||||
bind:value
|
||||
/>
|
||||
{:else if underlyingInputEl === 'div'}
|
||||
{@const { disabled, placeholder, ...divProps } = (inputProps ?? {}) as DivInputProps}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
role="textbox"
|
||||
aria-disabled={disabled}
|
||||
tabindex={disabled ? -1 : 0}
|
||||
contenteditable={!disabled}
|
||||
{...divProps}
|
||||
onkeydown={(e) => {
|
||||
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}
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user