Files
windmill/frontend/src/lib/components/common/EditableInput.svelte
T
GuilhemandClaude Opus 4.8 e4bfeb29bc fix(frontend): persist session-editor draft path/summary edits + per-line diff tooltips (#9622)
* 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>
2026-06-16 23:19:12 +02:00

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>