Files
windmill/frontend/src/lib/components/raw_apps/RawAppInlineScriptPanelList.svelte
T
GuilhemandClaude Opus 4.7 b0ed27096d feat(editors): responsive top-bars + collapsible raw-app sidebar (#9237)
* feat(editors): responsive top-bars + script test-pane pixel-min + flow graph overlay

Editor top bars now collapse on narrow widths (measured via container
clientWidth, not viewport — they live inside drawers / session panes
where the viewport stays wide):

- FlowBuilder: Diff + Save draft fold into the ellipsis menu when
  the top bar narrows below 720px (Save draft keeps its ⌘S / Ctrl+S
  shortcut indicator). Test-flow button moves out of the top bar
  and into a graph-pane overlay matching the dev page; the overlay
  position flips from top-2 right-2 to top-14 left-1/2 when the
  graph pane itself is narrower than 800px. FlowEditor exposes a
  graphOverlay snippet prop for that.
- ScriptBuilder: Settings + Draft labels collapse to icon-only;
  a new DropdownV2 ellipsis surfaces Tag / Settings / Save draft
  when even icons don't fit. The ellipsis itself uses variant=subtle.
- AppEditorHeader / RawAppEditorHeader: fullscreen / dark-mode /
  breakpoint toggle group + Debug-runs / Jobs buttons hide; Save
  draft moves into the Deploy dropdown.
- EditorBar: a "Helpers" DropdownV2 collapse for Context var /
  Variable / S3 / Resource / Git repo / Resource type / Database /
  Ducklake / Data table / Reset when the bar narrows below 800px
  (EDITOR_BAR_HELPERS_COMPACT_THRESHOLD). Above that, the existing
  icon-only mode (1420px threshold) still applies.
- ScriptEditor's test pane gets a pixel-based minimum width (400px)
  derived from the splitpane's clientWidth. The Pane uses Svelte 5
  function-binding so the splitter writes to a raw $state while the
  splitpane reads the clamped derived value — no $effect, no
  release-time bounce, drag stops at the boundary. Cap raised to
  80% so the test pane can take most of the editor on very narrow
  layouts while leaving a sliver of code visible.
- VS Code button on ScriptEditor: collapses to icon-only below the
  EDITOR_BAR_WIDTH_THRESHOLD (1420px) instead of being hidden
  entirely by viewport `lg:` breakpoint; hidden completely when the
  editor is rendered inside a session pane.
- AI wand button on ScriptEditor + RawAppEditorHeader: hidden inside
  a session pane (detected via `getContext('aiChatManager')`) — the
  session owns its own AI chat.
- DeployButton: drops the unused `newFlow` gate (callers updated).
- FlowDiffViewer / FlowGraphDiffViewer: inlineDiff prop forwarding
  + onHeight callback on FlowGraphV2 so diff viewers can equalize
  side-by-side graph heights.

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

* ui: unify Debug / wand / test-toggle button sizes + HideButton defaults

Two small consistency passes on shared button components:

- ScriptEditor's Debug, AI wand and Test-panel-toggle buttons all
  use unifiedSize="sm" so they line up in the toolbar; Test toggle
  switches from custom marine btnClasses to variant="accent-secondary".
  HideButton gains a passthrough unifiedSize prop so the wand and
  test toggle can match Debug without overriding btnClasses.
- HideButton's own defaults shift to variant="subtle" + sm
  unifiedSize, dropping the legacy color="light" / variant="contained"
  + tailwind-merge background overlay; the selected (hidden) state
  is now a tinted wrapper div instead of overriding btnClasses.

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

* ui(script): editor toolbar polish

Small consistency tweaks on the script editor's top-right overlay:

- Lowercase "test" / "Exit debug" panel labels.
- `bg-surface` on the overlay container so the absolute-positioned
  buttons read as a single panel over the graph rather than disjoint
  pills.
- Debug button picks up `destructive={debugMode}` so the active
  state reads as "you're in debug mode" instead of accent.
- Console and "Delegating to git repo" buttons drop the custom
  `btnClasses` border-on-surface treatment and switch from
  `size="xs"` to `unifiedSize="sm"` so they match the other buttons
  in the cluster.

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

* feat(raw-app): collapsible file sidebar, default-collapsed in session preview

The raw-app editor's left sidebar (file tree, runnables, history) ate a
lot of horizontal space — fine in the standalone editor, painful in the
session preview pane where the chat is already taking half the screen.

Add a small collapse / expand toggle. Persist the user's preference in
localStorage so it sticks across opens.

Two independent localStorage keys via the new `sidebarStorageKey` prop:
- standalone editor: `raw-app-sidebar-collapsed` (default expanded)
- session preview:   `raw-app-sidebar-collapsed-preview` (default collapsed)

Otherwise the two contexts would race for the same key — whichever
opens first would dictate the other's default. Splitting the keys lets
each have its own remembered preference.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fixup(editors): use untyped getContext for AI-chat-manager session detection

The cherry-picks landed `getContext<AIChatManager>('aiChatManager')` to
hide per-editor AI/VSCode buttons when rendered inside a session pane.
The `AIChatManager` class is exported only on the sessions branch (used
for typing session-provided manager overrides). On `main` the manager
file exports only the singleton instance, so importing the class fails
the type-check.

The session-pane detection just needs a truthy/falsy probe — drop the
type parameter and the class import. `inSessionPane` ends up as
`getContext('aiChatManager')` (returns `unknown`, coerced to boolean
via `!!`). Same runtime behaviour, no class-export dependency.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* ui(editors): flow test in top bar; ellipsis folds draft/jobs/tutorials

* refactor(app-editor): drop dead AppEditorTutorial button path

* ui(editors): wire compactHelpers in flow-step + raw-app inline editors

* ui(raw-app): sidebar Cmd/Ctrl+B toggle + uppercase section titles

* ui(editors): keep Diff/Settings inline as icon-only when narrow

* fix(editors): address review nits on test-pane/Helpers/thresholds

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:55:20 +00:00

218 lines
6.1 KiB
Svelte

<script lang="ts">
import { Button } from '$lib/components/common'
import { Plus, File, AlertCircle, AlertTriangle } from 'lucide-svelte'
import PanelSection, {
SUBTLE_PANEL_TITLE
} from '../apps/editor/settingsPanel/common/PanelSection.svelte'
import Popover from '../Popover.svelte'
import type { Runnable } from '../apps/inputType'
import { getNextId, forbiddenIds } from '$lib/components/flows/idUtils'
import { rawAppLintStore } from './lintStore'
import RunnableRow from './RunnableRow.svelte'
import { sendUserToast } from '$lib/toast'
interface Props {
selectedRunnable: string | undefined
runnables: Record<string, Runnable>
onSelect?: (id: string) => void
}
let { selectedRunnable = $bindable(), runnables, onSelect }: Props = $props()
let editingId: string | undefined = $state(undefined)
const validIdPattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/
function renameRunnable(oldId: string, newId: string) {
if (newId === oldId) {
editingId = undefined
return
}
if (!newId) {
sendUserToast('ID cannot be empty', true)
editingId = undefined
return
}
if (!validIdPattern.test(newId)) {
sendUserToast('ID must be a valid identifier (letters, digits, underscores)', true)
editingId = undefined
return
}
if (newId in runnables) {
sendUserToast(`ID "${newId}" is already in use`, true)
editingId = undefined
return
}
if (forbiddenIds.includes(newId)) {
sendUserToast(`"${newId}" is a reserved keyword`, true)
editingId = undefined
return
}
runnables[newId] = runnables[oldId]
delete runnables[oldId]
if (selectedRunnable === oldId) {
selectedRunnable = newId
onSelect?.(newId)
}
editingId = undefined
}
// Subscribe to lint store for reactive updates
let lintSnapshot = $state(rawAppLintStore.getSnapshot())
$effect(() => {
const unsubscribe = rawAppLintStore.subscribe((s) => {
lintSnapshot = s
})
return unsubscribe
})
const errorCount = $derived(
Object.values(lintSnapshot.errors).reduce((acc, arr) => acc + arr.length, 0)
)
const warningCount = $derived(
Object.values(lintSnapshot.warnings).reduce((acc, arr) => acc + arr.length, 0)
)
function createBackgroundScript() {
const nid = getNextId(Object.keys(runnables ?? {}))
const newScriptPath = `Backend Runnable ${nid}`
runnables[nid] = {
name: newScriptPath,
inlineScript: undefined,
type: 'inline'
}
selectedRunnable = nid
onSelect?.(nid)
}
</script>
<PanelSection
size="sm"
fullHeight={false}
title="backend"
titleClass={SUBTLE_PANEL_TITLE}
id="app-editor-runnable-panel"
>
{#snippet action()}
<div class="flex flex-row gap-1">
<Button
unifiedSize="xs"
variant="subtle"
title="Create a new background runnable"
aria-label="Create a new background runnable"
on:click={createBackgroundScript}
id="create-background-runnable"
btnClasses="gap-0.5 px-1"
>
<Plus size={12} />
<File size={12} />
</Button>
</div>
{/snippet}
<div class="w-full flex flex-col gap-6 py-1">
<div>
<div class="flex flex-col gap-1 w-full">
{#if Object.keys(runnables ?? {}).length > 0}
{#each Object.entries(runnables ?? {}) as [id, runnable]}
{#if runnable}
<RunnableRow
{id}
{runnable}
isSelected={selectedRunnable === id}
isEditing={editingId === id}
onSelect={() => {
selectedRunnable = id
onSelect?.(id)
}}
onDelete={() => {
delete runnables[id]
if (selectedRunnable === id) {
selectedRunnable = undefined
}
}}
onRename={(newId) => renameRunnable(id, newId)}
onRequestEdit={() => (editingId = id)}
onCancelEdit={() => (editingId = undefined)}
/>
{/if}
{/each}
{:else}
<div class="text-xs text-primary">No backend runnable</div>
{/if}
</div>
{#if errorCount > 0 || warningCount > 0}
<Popover notClickable placement="right">
<div
class="flex items-center gap-2 px-2 py-1.5 mt-2 rounded border text-xs
{errorCount > 0
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800'}"
>
{#if errorCount > 0}
<span class="flex items-center gap-1 text-red-600 dark:text-red-400">
<AlertCircle size={14} />
{errorCount} error{errorCount !== 1 ? 's' : ''}
</span>
{/if}
{#if warningCount > 0}
<span class="flex items-center gap-1 text-yellow-600 dark:text-yellow-400">
<AlertTriangle size={14} />
{warningCount} warning{warningCount !== 1 ? 's' : ''}
</span>
{/if}
</div>
{#snippet text()}
<div class="max-w-md max-h-80 overflow-auto text-xs">
{#if errorCount > 0}
<div class="mb-2">
<div class="font-semibold text-red-600 dark:text-red-400 mb-1">Errors</div>
{#each Object.entries(lintSnapshot.errors) as [key, errors]}
{#if errors.length > 0}
<div class="mb-1">
<span class="font-medium text-secondary">{key}:</span>
<ul class="ml-2 list-disc list-inside">
{#each errors as error}
<li class="text-tertiary">
Line {error.startLineNumber}: {error.message}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
</div>
{/if}
{#if warningCount > 0}
<div>
<div class="font-semibold text-yellow-600 dark:text-yellow-400 mb-1">Warnings</div
>
{#each Object.entries(lintSnapshot.warnings) as [key, warnings]}
{#if warnings.length > 0}
<div class="mb-1">
<span class="font-medium text-secondary">{key}:</span>
<ul class="ml-2 list-disc list-inside">
{#each warnings as warning}
<li class="text-tertiary">
Line {warning.startLineNumber}: {warning.message}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
</div>
{/if}
</div>
{/snippet}
</Popover>
{/if}
</div>
</div>
</PanelSection>