Files
windmill/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte
T
GuilhemandClaude Opus 4.8 46288b6143 fix(frontend): session Drafts drawer uses raw_app kind for the raw-app diff (#9617)
* fix(frontend): session Drafts drawer uses raw_app kind for the raw-app diff

Follow-up to #9601. DraftDiffDrawer mapped a raw_app row back to `app` before
calling getDraftDiffValues(), but that helper sends `rawApp:true` only for the
exact kind `raw_app` (which a never-deployed raw app needs). With `app` it hit
the normal app endpoint and 404'd instead of rendering the added diff. `raw_app`
isn't in the deploy-kind maps anyway, so just pass the row kind through.

Caught by the Codex auto-review on #9601, which posted after that PR had already
merged (locked conversation), so the fix lands separately here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): show friendly draft path + summary for all kinds in session Drafts drawer

A never-deployed app/raw_app is parked at a synthetic `u/.../draft_<uuid>`
storage path with the user's typed name in the draft JSON's `draft_path`; the
Drafts drawer rendered that UUID path. The list endpoint already returns
`draft_path` and `summary` for every kind, but `fetchDrafts` dropped them and
the drawer only had the lazily-derived summary.

Thread both through the shared row: `WorkspaceDiffDrawer` gains optional
`displayPath` (shown in tree/header/search, while `path` stays the storage key
for value-loading, item keys and edit links) and `summary` (preferred over the
value-derived one, shown before the diff loads). `DraftDiffDrawer` populates
them from the draft list (`draft_path ?? path`, `summary`).

Both fields are opt-in via `?? path` / lazy fallback, so ForkDiffDrawer — the
other consumer of the component — is unchanged. The symptom only surfaced for
apps/raw apps because their storage path diverges from the friendly name;
scripts already kept a readable path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(frontend): use friendly display path for single-segment draft tree nodes

buildTree splits displayPathOf(d), but the `< 2 parts` branch still named the
file node from the storage `path` — a draft whose friendly path is a bare name
(no `/`) would show `…/draft_<uuid>` in the sidebar tree. Name it from
displayPathOf(d) too, consistent with the rest of the tree/header/search.

Addresses Codex and claude review nits on #9617.

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 17:11:01 +02:00

654 lines
20 KiB
Svelte

<script lang="ts" module>
export type DiffStatus = 'added' | 'removed' | 'modified' | 'conflict'
// One changed item. `status` drives the dot/badge; `ahead`/`behind` are
// optional (fork-only) and only render when present.
export type DiffRow = {
kind: string
path: string
status: DiffStatus
ahead?: number
behind?: number
/** Human-facing path; defaults to `path`. Lets a draft parked at a
* synthetic storage path (`…/draft_<uuid>`) show its friendly typed path
* while keys, value-loading and edit links stay keyed on `path`. */
displayPath?: string
/** Summary supplied by the data source. Preferred over the one derived
* from the loaded diff value, and shown before that value loads. */
summary?: string
}
</script>
<script lang="ts">
import { parentFolderKey } from './forkDiffNav'
import { Button, Drawer, DrawerContent } from '$lib/components/common'
import Badge from '$lib/components/common/badge/Badge.svelte'
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Folder,
GitMerge,
Loader2,
Minus,
Pencil,
Plus,
User
} from 'lucide-svelte'
import { goto } from '$lib/navigation'
import RowIcon from '$lib/components/common/table/RowIcon.svelte'
import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte'
import WorkspaceItemDiffViewer from '$lib/components/WorkspaceItemDiffViewer.svelte'
import SearchItems from '$lib/components/SearchItems.svelte'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { DiffIcon, SquareSplitHorizontal } from 'lucide-svelte'
import { untrack, type Snippet } from 'svelte'
import ExternalEditLink from '../ExternalEditLink.svelte'
// Read-only, multi-item before/after diff viewer with a file tree. The data
// source (fork comparison vs deployed-vs-draft) is supplied by the parent
// wrapper via `diffs` + `loadValues`; everything here is generic rendering.
let {
diffs,
loadValues,
loading = false,
error = undefined,
notice = undefined,
emptyMessage = 'No changes.',
title,
reviewHref,
reviewLabel = 'Review',
editUrlFor = undefined,
titleExtra
}: {
diffs: DiffRow[]
loadValues: (d: DiffRow) => Promise<{ before: unknown; after: unknown }>
loading?: boolean
error?: string | undefined
/** Replaces the diff list with an informational message (e.g. fork
* created before change tracking). */
notice?: string | undefined
emptyMessage?: string
title: string
reviewHref: string
reviewLabel?: string
editUrlFor?: (d: DiffRow) => string | undefined
titleExtra?: Snippet
} = $props()
let drawer: Drawer | undefined = $state(undefined)
let searchQuery = $state('')
let diffStyle = $state<'sbs' | 'inline'>('sbs')
const inlineDiff = $derived(diffStyle === 'inline')
export function open() {
// Reset per-item caches so an edit-then-reopen doesn't show stale
// expanded content. The wrapper re-fetches `diffs`, which re-triggers
// the eager-load effect below.
loadedDiffs = {}
summaries = {}
drawer?.openDrawer()
setTimeout(() => searchInputEl?.focus(), 50)
}
function itemKey(d: DiffRow): string {
return `${d.kind}/${d.path}`
}
// Friendly path for display only; `path` stays the storage key everywhere
// keys/loads happen, so a never-deployed draft still loads from `…/draft_<uuid>`.
function displayPathOf(d: DiffRow): string {
return d.displayPath ?? d.path
}
const KIND_LABELS: Record<string, string> = {
script: 'Script',
flow: 'Flow',
app: 'App',
raw_app: 'Raw app',
resource: 'Resource',
variable: 'Variable',
resource_type: 'Resource type',
folder: 'Folder',
schedule: 'Schedule',
http_trigger: 'HTTP route',
websocket_trigger: 'Websocket trigger',
kafka_trigger: 'Kafka trigger',
nats_trigger: 'NATS trigger',
postgres_trigger: 'Postgres trigger',
mqtt_trigger: 'MQTT trigger',
sqs_trigger: 'SQS trigger',
gcp_trigger: 'GCP trigger',
azure_trigger: 'Azure trigger',
email_trigger: 'Email trigger'
}
type LoadedDiff = {
state: 'loading' | 'ready' | 'error'
error?: string
before?: unknown
after?: unknown
}
let loadedDiffs: Record<string, LoadedDiff> = $state({})
let summaries: Record<string, string | undefined> = $state({})
async function loadDiffFor(d: DiffRow) {
const key = itemKey(d)
if (loadedDiffs[key]) return
loadedDiffs[key] = { state: 'loading' }
try {
const { before, after } = await loadValues(d)
loadedDiffs[key] = { state: 'ready', before, after }
const summary =
(after && typeof after === 'object' && (after as any).summary) ||
(before && typeof before === 'object' && (before as any).summary) ||
undefined
if (typeof summary === 'string' && summary.trim().length > 0) {
summaries[key] = summary
}
} catch (e) {
console.error('WorkspaceDiffDrawer: loadValues failed', d, e)
loadedDiffs[key] = { state: 'error', error: String(e) }
}
}
// Eagerly load each row (diffs render expanded). Tracks `diffs` only; the
// cache reads/writes are untracked so this doesn't loop on itself.
$effect(() => {
const ds = diffs
untrack(() => {
for (const d of ds) void loadDiffFor(d)
})
})
function onDetailsToggle(d: DiffRow, e: Event) {
const target = e.currentTarget as HTMLDetailsElement | null
if (target?.open) void loadDiffFor(d)
}
function statusBadgeColor(s: DiffStatus): 'green' | 'red' | 'orange' | 'blue' {
if (s === 'added') return 'green'
if (s === 'removed') return 'red'
if (s === 'conflict') return 'orange'
return 'blue'
}
const statusIcons = { added: Plus, removed: Minus, modified: Pencil, conflict: AlertTriangle }
// ── File tree ───────────────────────────────────────────────────────────
type FolderNode = {
type: 'folder'
name: string
fullPath: string
isScope: boolean
children: TreeNode[]
}
type FileNode = { type: 'file'; name: string; diff: DiffRow }
type TreeNode = FolderNode | FileNode
function buildTree(rows: DiffRow[]): FolderNode {
const root: FolderNode = {
type: 'folder',
name: '',
fullPath: '',
isScope: false,
children: []
}
const folderCache = new Map<string, FolderNode>()
for (const d of rows) {
const parts = displayPathOf(d).split('/')
if (parts.length < 2) {
root.children.push({ type: 'file', name: displayPathOf(d), diff: d })
continue
}
const scopeKey = parts.slice(0, 2).join('/')
let scope = folderCache.get(scopeKey)
if (!scope) {
scope = { type: 'folder', name: scopeKey, fullPath: scopeKey, isScope: true, children: [] }
folderCache.set(scopeKey, scope)
root.children.push(scope)
}
if (parts.length === 2) {
scope.children.push({ type: 'file', name: parts[1], diff: d })
continue
}
const rest = parts.slice(2)
let parent = scope
let fkey = scopeKey
for (let i = 0; i < rest.length - 1; i++) {
fkey = `${fkey}/${rest[i]}`
let folder = folderCache.get(fkey)
if (!folder) {
folder = { type: 'folder', name: rest[i], fullPath: fkey, isScope: false, children: [] }
folderCache.set(fkey, folder)
parent.children.push(folder)
}
parent = folder
}
parent.children.push({ type: 'file', name: rest[rest.length - 1], diff: d })
}
const sortRec = (n: FolderNode) => {
n.children.sort((a, b) => {
if (a.type !== b.type) return a.type === 'folder' ? -1 : 1
return a.name.localeCompare(b.name)
})
for (const c of n.children) if (c.type === 'folder') sortRec(c)
}
sortRec(root)
return root
}
function searchableText(d: DiffRow): string {
const parts = [displayPathOf(d), KIND_LABELS[d.kind] ?? d.kind]
const s = summaries[itemKey(d)] ?? d.summary
if (s) parts.push(s)
return parts.join(' ')
}
let searchedDiffs: (DiffRow & { marked?: string })[] | undefined = $state(undefined)
const filteredDiffs = $derived.by(() => {
const q = searchQuery.trim()
if (!q) return diffs
return (searchedDiffs ?? []) as DiffRow[]
})
const tree = $derived(buildTree(filteredDiffs))
function rowId(d: DiffRow): string {
return `ws-diff-${itemKey(d)}`
}
function scrollToDiff(d: DiffRow) {
const el = document.getElementById(rowId(d)) as HTMLDetailsElement | null
if (!el) return
el.open = true
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// ── Keyboard nav (matches WorkspaceItemDrillPicker) ─────────────────────
let folderOpen: Record<string, boolean> = $state({})
function isFolderOpen(key: string): boolean {
return folderOpen[key] ?? true
}
function folderKey(node: FolderNode): string {
return `folder:${node.fullPath}`
}
type NavEntry =
| { type: 'folder'; key: string; node: FolderNode }
| { type: 'file'; key: string; diff: DiffRow }
function flattenVisible(node: FolderNode): NavEntry[] {
const out: NavEntry[] = []
const walk = (n: TreeNode) => {
if (n.type === 'file') {
out.push({ type: 'file', key: itemKey(n.diff), diff: n.diff })
return
}
const fkey = folderKey(n)
out.push({ type: 'folder', key: fkey, node: n })
if (isFolderOpen(fkey)) for (const c of n.children) walk(c)
}
for (const c of node.children) walk(c)
return out
}
const navEntries = $derived(flattenVisible(tree))
const navKeys = $derived(navEntries.map((e) => e.key))
const entryByKey = $derived(new Map(navEntries.map((e) => [e.key, e])))
let highlightedKey: string | undefined = $state(undefined)
let mouseActive = $state(false)
let searchInputEl: HTMLInputElement | undefined = $state()
let sidebarRoot: HTMLElement | undefined = $state()
$effect(() => {
if (navKeys.length === 0) return
if (!highlightedKey || !navKeys.includes(highlightedKey)) {
highlightedKey = navKeys[0]
}
})
function scrollHighlightIntoView() {
if (!sidebarRoot || !highlightedKey) return
const el = sidebarRoot.querySelector<HTMLElement>(
`[data-nav-key="${CSS.escape(highlightedKey)}"]`
)
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
}
function moveHighlight(delta: 1 | -1) {
if (navKeys.length === 0) return
const cur = navKeys.indexOf(highlightedKey ?? '')
const next = cur < 0 ? 0 : (cur + delta + navKeys.length) % navKeys.length
highlightedKey = navKeys[next]
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function setHoverHighlight(key: string) {
if (mouseActive) highlightedKey = key
}
function activateHighlighted() {
if (!highlightedKey) return
const entry = entryByKey.get(highlightedKey)
if (!entry) return
if (entry.type === 'file') {
scrollToDiff(entry.diff)
} else {
folderOpen[entry.key] = !isFolderOpen(entry.key)
}
}
function parentFolderKeyFor(entry: NavEntry): string | undefined {
const path = entry.type === 'folder' ? entry.node.fullPath : entry.diff.path
return parentFolderKey(entry.type, path)
}
function firstChildKey(node: FolderNode): string | undefined {
const c = node.children[0]
if (!c) return undefined
return c.type === 'folder' ? folderKey(c) : itemKey(c.diff)
}
function selectKey(key: string) {
highlightedKey = key
mouseActive = false
requestAnimationFrame(scrollHighlightIntoView)
}
function handleSearchKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
moveHighlight(1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
moveHighlight(-1)
} else if (e.key === 'Enter') {
e.preventDefault()
activateHighlighted()
} else if (e.key === 'ArrowRight') {
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry || entry.type !== 'folder') return
if (!isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = true
return
}
const child = firstChildKey(entry.node)
if (child) {
e.preventDefault()
selectKey(child)
}
} else if (e.key === 'ArrowLeft') {
const entry = highlightedKey ? entryByKey.get(highlightedKey) : undefined
if (!entry) return
if (entry.type === 'folder' && isFolderOpen(entry.key)) {
e.preventDefault()
folderOpen[entry.key] = false
return
}
const parent = parentFolderKeyFor(entry)
if (parent && entryByKey.has(parent)) {
e.preventDefault()
selectKey(parent)
}
}
}
</script>
<SearchItems
filter={searchQuery}
items={diffs}
bind:filteredItems={searchedDiffs}
f={(d: DiffRow) => searchableText(d)}
/>
{#snippet renderTreeNode(node: TreeNode, depth: number)}
{#if node.type === 'folder'}
{@const isUserScope = node.isScope && node.name.startsWith('u/')}
{@const fkey = folderKey(node)}
{@const open = isFolderOpen(fkey)}
{@const isHl = fkey === highlightedKey}
<details
{open}
ontoggle={(e) => (folderOpen[fkey] = (e.currentTarget as HTMLDetailsElement).open)}
class="select-none"
>
<summary
role="option"
aria-selected={isHl}
data-nav-key={fkey}
onmouseenter={() => setHoverHighlight(fkey)}
class="flex items-center gap-1.5 px-3 py-1 cursor-pointer text-xs font-medium font-mono text-emphasis hover:bg-surface-hover list-none [&::-webkit-details-marker]:hidden tree-summary {isHl
? 'bg-surface-hover'
: ''}"
style="padding-left: {depth * 12 + 8}px"
>
<ChevronDown class="w-3 h-3 shrink-0 text-tertiary tree-chevron-open" />
<ChevronRight class="w-3 h-3 shrink-0 text-tertiary tree-chevron-closed" />
{#if isUserScope}
<User size={12} class="shrink-0 text-tertiary" />
{:else}
<Folder size={12} class="shrink-0 text-tertiary" />
{/if}
<span class="truncate" title={node.name}>{node.name}</span>
</summary>
<div>
{#each node.children as child}
{@render renderTreeNode(child, depth + 1)}
{/each}
</div>
</details>
{:else}
{@const status = node.diff.status}
{@const key = itemKey(node.diff)}
<WorkspaceItemRow
kind={node.diff.kind as any}
uniformHeight
summary={summaries[key] ?? node.diff.summary}
secondary={node.name}
highlighted={key === highlightedKey}
navKey={key}
indent={depth * 12 + 20}
title={displayPathOf(node.diff)}
onclick={() => {
highlightedKey = key
scrollToDiff(node.diff)
}}
onmouseenter={() => setHoverHighlight(key)}
>
{#snippet extras()}
<span
class="w-1.5 h-1.5 rounded-full shrink-0 {status === 'added'
? 'bg-green-500'
: status === 'removed'
? 'bg-red-500'
: status === 'conflict'
? 'bg-orange-500'
: 'bg-blue-500'}"
></span>
{/snippet}
</WorkspaceItemRow>
{/if}
{/snippet}
<Drawer bind:this={drawer} size="1200px">
<DrawerContent
{title}
{titleExtra}
on:close={() => drawer?.closeDrawer()}
documentationLink={undefined}
noPadding
overflow_y={false}
>
{#snippet actions()}
<ToggleButtonGroup bind:selected={diffStyle} noWFull>
{#snippet children({ item })}
<ToggleButton
value="sbs"
label="Side-by-side"
icon={SquareSplitHorizontal}
tooltip="Side-by-side diff"
iconOnly
{item}
/>
<ToggleButton
value="inline"
label="Unified"
icon={DiffIcon}
tooltip="Unified diff"
iconOnly
{item}
/>
{/snippet}
</ToggleButtonGroup>
<Button
variant="accent"
unifiedSize="sm"
startIcon={{ icon: GitMerge }}
onclick={() => goto(reviewHref)}
>
{reviewLabel}
</Button>
{/snippet}
<div class="flex flex-row h-full min-h-0">
{#if diffs.length > 0}
<aside
bind:this={sidebarRoot}
onmousemove={() => (mouseActive = true)}
class="flex-none w-56 border-r border-light flex flex-col min-h-0"
>
<div class="px-3 pt-3 pb-2 shrink-0">
<!-- Raw input (not the design-system TextInput) on purpose: this is a
bespoke filter wired to the file tree's keyboard navigation — it
needs a direct element ref to focus (the `/` shortcut) and a
keydown handler that hands ArrowDown/Enter off to the tree.
TextInput/ClearableInput swallow/rebubble those and don't expose
the element ref. -->
<input
bind:this={searchInputEl}
type="search"
bind:value={searchQuery}
placeholder="Filter files..."
onkeydown={handleSearchKeydown}
class="w-full text-xs px-2 py-1 rounded border border-light bg-surface focus:outline-none focus:border-accent"
/>
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-3 flex flex-col gap-1">
{#if tree.children.length > 0}
{#each tree.children as child}
{@render renderTreeNode(child, 0)}
{/each}
{:else}
<div class="text-2xs text-tertiary px-3 py-2">No matches</div>
{/if}
</div>
</aside>
{/if}
<main class="flex-1 min-w-0 overflow-y-auto">
<div class="px-3 pt-3 pb-4 flex flex-col gap-3">
{#if loading && diffs.length === 0}
<div class="flex items-center gap-2 text-sm text-secondary py-8 self-center">
<Loader2 class="w-4 h-4 animate-spin" />
Loading comparison...
</div>
{:else if error}
<div class="text-sm text-red-600 dark:text-red-400 py-4">{error}</div>
{:else if notice}
<div class="text-sm text-secondary py-4">{notice}</div>
{:else if diffs.length === 0}
<div class="text-sm text-secondary py-4">{emptyMessage}</div>
{:else if filteredDiffs.length === 0}
<div class="text-sm text-secondary py-4">No files match "{searchQuery}".</div>
{:else}
<div class="flex flex-col gap-2">
{#each filteredDiffs as d (itemKey(d))}
{@const key = itemKey(d)}
{@const status = d.status}
{@const StatusIcon = statusIcons[status]}
{@const loaded = loadedDiffs[key]}
{@const editUrl = editUrlFor?.(d)}
{@const dpath = displayPathOf(d)}
<details
open
id={rowId(d)}
class="border border-light rounded-md bg-surface scroll-mt-2"
ontoggle={(e) => onDetailsToggle(d, e)}
>
<summary
class="sticky top-0 z-30 bg-surface flex items-center gap-2 px-3 py-2 cursor-pointer list-none [&::-webkit-details-marker]:hidden border-b border-transparent rounded-md relative before:content-[''] before:absolute before:inset-0 before:bg-surface-hover before:opacity-0 before:pointer-events-none before:transition-opacity hover:before:opacity-100"
>
<ChevronDown
class="w-3.5 h-3.5 shrink-0 text-tertiary transition-transform chevron"
/>
<RowIcon kind={d.kind as any} size={14} />
<div class="min-w-0 flex-1">
{#if editUrl}
<ExternalEditLink
href={editUrl}
title={dpath}
class="text-xs text-primary font-mono truncate"
>
<span class="truncate">{dpath}</span>
</ExternalEditLink>
{:else}
<div class="text-xs text-primary font-mono truncate" title={dpath}>
{dpath}
</div>
{/if}
</div>
<div class="shrink-0 flex items-center gap-2">
{#if d.ahead && d.ahead > 0}
<span class="text-2xs text-secondary">{d.ahead} ahead</span>
{/if}
{#if d.behind && d.behind > 0}
<span class="text-2xs text-secondary">{d.behind} behind</span>
{/if}
<Badge color={statusBadgeColor(status)}>
<StatusIcon class="w-3 h-3 inline mr-0.5" />
{status}
</Badge>
</div>
</summary>
<div
class="border-t border-light bg-surface-tertiary rounded-b-md overflow-hidden"
>
{#if !loaded || loaded.state === 'loading'}
<div class="flex items-center gap-2 text-xs text-secondary p-3">
<Loader2 class="w-3.5 h-3.5 animate-spin" />
Loading diff…
</div>
{:else if loaded.state === 'error'}
<div class="text-xs text-red-600 dark:text-red-400">{loaded.error}</div>
{:else if loaded.state === 'ready'}
<WorkspaceItemDiffViewer
kind={d.kind}
originalRaw={loaded.before}
currentRaw={loaded.after}
{inlineDiff}
/>
{/if}
</div>
</details>
{/each}
</div>
{/if}
</div></main
>
</div>
</DrawerContent>
</Drawer>
<style>
details:not([open]) :global(.chevron) {
transform: rotate(-90deg);
}
details:not([open]) > .tree-summary :global(.tree-chevron-open) {
display: none;
}
details[open] > .tree-summary :global(.tree-chevron-closed) {
display: none;
}
</style>