mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-08 08:04:25 +00:00
* fix(frontend): persist raw-app draft path edits in the session editor Renaming a raw app's path in the session preview editor never triggered a draft save: the header surfaced the typed path as `pendingDraftPath`, but RawAppEditorView ignored it (it never reached runtime.rawApp.val), and the RawAppDraft codec didn't serialize a path field — so the autosave signature (JSON.stringify(draft)) was unchanged and nothing was written. The rename was lost and the home/review/Drafts lists kept the original `draft_path`. - appDraftCodec: make `draft_path` a real draft + runtime field, serialized by runtimeRawAppToDraft and round-tripped by applyDraftToRuntimeRawApp, so a path change moves the sig and fires a save. - sessionRuntime.loadRawApp (+ inline rawApp.val type): seed `draft_path` from the loaded draft so it survives reloads. - RawAppEditorView: bind the header's `pendingDraftPath`, mirror it into runtime.rawApp.val.draft_path (guarded so the initial undefined can't clobber the seed or fire a spurious save), and seed the path widget from `draft_path ?? path`. Mirrors the full-page /apps_raw/edit route. - appDraftCodec.test: add a draft_path round-trip test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): per-line tooltips in workspace item diff rows The summary line now shows the full summary on hover and the path line the full path, instead of one row-level title surfacing the path everywhere. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): persist flow/script draft path edits in the session editor The session sync dedups on a per-kind signature that omitted the path, so a rename never moved the signature and never autosaved. Add path/draft_path to the flow signature, and derive draft_path in the script codec (scripts bind the Path widget to script.path directly) so the rename both autosaves and shows the typed name in the home/Drafts lists. Mirrors the raw-app fix. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): commit editor summary edits live instead of on blur EditableInput only fired onSave on Enter/blur, so the summary in the shared editor header only updated when the field lost focus. Add an opt-in commitOnInput that fires onSave per keystroke and enable it for the header summary, so flow/script/raw-app summaries autosave as you type. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): preserve renamed never-deployed script path on session re-seed loadScript seeded a draft-only script's baseline path from the storage key, so re-running it with the draft still in memory (e.g. a script→script switch) reset the path to draft_<uuid> and the next autosave dropped draft_path, clobbering the rename. Seed from the draft's own draft_path/path instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(frontend): clear raw-app draft_path when the path rename is reverted The mirror effect only ever set draft_path, so reverting/clearing the path field left a stale friendly name in the draft (persisted by the codec and shown in the home/Drafts lists). Track whether a real typed path was surfaced so a revert clears draft_path while the initial pre-bind undefined still can't clobber the loadRawApp-seeded value. Mirrors the script codec's drop-on-revert. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
173 lines
5.5 KiB
Svelte
173 lines
5.5 KiB
Svelte
<!--
|
|
@component
|
|
Inline-editable text. Renders as a static button in idle mode; clicking it
|
|
swaps to a `TextInput` whose width tracks the content (no layout shift on
|
|
toggle). `Enter` or `blur` commits via `onSave`; `Escape` discards.
|
|
|
|
Use it for header titles, summaries, list-item names — anywhere the user
|
|
should be able to edit a label in place without opening a modal or popover.
|
|
|
|
```svelte
|
|
<EditableInput
|
|
value={summary}
|
|
placeholder="Add a summary..."
|
|
onSave={(v) => (summary = v)}
|
|
textClass="text-xs font-semibold text-emphasis"
|
|
/>
|
|
```
|
|
|
|
The current value isn't bound — `onSave` is fired with the trimmed draft
|
|
whenever it differs from the prior `value`, including with `''` when the
|
|
user clears the field. Callers that want to reject empty commits should
|
|
guard inside their `onSave` handler. The parent owns the canonical state;
|
|
this component just proposes new values.
|
|
-->
|
|
<script lang="ts">
|
|
import TextInput from '$lib/components/text_input/TextInput.svelte'
|
|
|
|
interface Props {
|
|
/** Current value displayed in idle mode and pre-filled when entering edit mode. */
|
|
value: string
|
|
/** Shown when `value` is empty, in both idle and editing modes. */
|
|
placeholder?: string
|
|
/**
|
|
* Called when the user commits a changed value (Enter or blur, or every
|
|
* keystroke when {@link commitOnInput} is set). Fires with the trimmed
|
|
* draft, including `''` if the user cleared the field. Not called on
|
|
* Escape, or when the trimmed draft matches the prior `value`. Guard
|
|
* against empty in your handler if needed.
|
|
*/
|
|
onSave?: (newValue: string) => void
|
|
/**
|
|
* Fire `onSave` on every keystroke (still trimmed, still de-duped against
|
|
* the prior `value`) instead of only on Enter/blur. Use when the parent
|
|
* autosaves the field and the user expects edits to land live rather than
|
|
* on focus-out. Escape no longer discards (live commits already
|
|
* propagated). Off by default to preserve the commit-on-blur contract.
|
|
*/
|
|
commitOnInput?: boolean
|
|
/** When false, the component renders as plain text (not clickable). Default true. */
|
|
editable?: boolean
|
|
/** TextInput size in editing mode. Idle mode is unaffected (text only). */
|
|
size?: 'xs' | 'sm' | 'md' | 'lg'
|
|
/** Wrapper classes. Use for layout (margin, max-width, alignment) only — not text styling. */
|
|
class?: string
|
|
/** Extra classes on the inner `<input>` in editing mode. Background/border/shadow are reset on top of these. */
|
|
inputClass?: string
|
|
/**
|
|
* Text styling (font-size, weight, color, line-height...) applied to *both*
|
|
* the idle button and the editing input so the two render identically and
|
|
* the toggle doesn't visually shift.
|
|
*/
|
|
textClass?: string
|
|
}
|
|
|
|
let {
|
|
value,
|
|
placeholder = '',
|
|
onSave,
|
|
commitOnInput = false,
|
|
editable = true,
|
|
size = 'sm',
|
|
class: className = '',
|
|
inputClass = '',
|
|
textClass = ''
|
|
}: Props = $props()
|
|
|
|
let editing = $state(false)
|
|
let draft = $state('')
|
|
let textInputComponent: TextInput | undefined = $state(undefined)
|
|
|
|
function startEditing() {
|
|
if (!editable) return
|
|
editing = true
|
|
draft = value ?? ''
|
|
requestAnimationFrame(() => {
|
|
textInputComponent?.focus()
|
|
textInputComponent?.select()
|
|
})
|
|
}
|
|
|
|
// External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap
|
|
// stays active for a brief window after the menu closes — focusing our
|
|
// input during that window causes checkFocusIn to slam focus back out, which
|
|
// fires onblur=save and instantly closes the edit. A 50ms defer is enough
|
|
// for Melt's trap to release.
|
|
export function edit() {
|
|
setTimeout(startEditing, 50)
|
|
}
|
|
|
|
function save() {
|
|
// Re-entry guard: Enter calls `save()` and sets `editing = false`,
|
|
// which unmounts the `<input>` and synchronously fires its `blur`
|
|
// handler — also `save()`. Without this guard, `onSave` would fire
|
|
// twice for the same edit.
|
|
if (!editing) return
|
|
editing = false
|
|
const trimmed = draft.trim()
|
|
if (trimmed !== (value ?? '')) {
|
|
onSave?.(trimmed)
|
|
}
|
|
}
|
|
|
|
function handleKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') save()
|
|
else if (e.key === 'Escape') editing = false
|
|
}
|
|
|
|
// Live commit: propagate each keystroke to the parent (trimmed, de-duped)
|
|
// so an autosaving field updates as you type instead of only on focus-out.
|
|
// Reads the DOM value directly so it's correct regardless of the `bind:value`
|
|
// update order.
|
|
function handleLiveInput(e: Event) {
|
|
const next = (e.currentTarget as HTMLInputElement).value.trim()
|
|
if (next !== (value ?? '')) onSave?.(next)
|
|
}
|
|
</script>
|
|
|
|
{#if editing}
|
|
<span
|
|
class="input-sizer inline-grid items-center {textClass} {className}"
|
|
data-value={draft || placeholder}
|
|
>
|
|
<TextInput
|
|
bind:this={textInputComponent}
|
|
bind:value={draft}
|
|
{size}
|
|
class="!bg-transparent !border-0 !shadow-none -p-0 !m-0 !min-w-0 !min-h-0 !h-auto {textClass} {inputClass}"
|
|
inputProps={{
|
|
placeholder,
|
|
onblur: save,
|
|
onkeydown: handleKeydown,
|
|
oninput: commitOnInput ? handleLiveInput : undefined,
|
|
spellcheck: false,
|
|
size: 1,
|
|
style: 'padding: 2px !important; grid-area: 1 / 1'
|
|
}}
|
|
/>
|
|
</span>
|
|
{:else}
|
|
<button
|
|
type="button"
|
|
onclick={startEditing}
|
|
disabled={!editable}
|
|
aria-label={editable ? `Edit ${placeholder.toLowerCase() || 'value'}` : undefined}
|
|
class="text-left truncate rounded p-0.5 {editable
|
|
? 'cursor-text hover:bg-surface-hover'
|
|
: 'cursor-default'} {textClass} {className}"
|
|
>
|
|
{value || placeholder}
|
|
</button>
|
|
{/if}
|
|
|
|
<style>
|
|
.input-sizer::after {
|
|
content: attr(data-value) ' ';
|
|
visibility: hidden;
|
|
white-space: pre;
|
|
grid-area: 1 / 1;
|
|
font: inherit;
|
|
padding: 2px;
|
|
}
|
|
</style>
|