1
0
forked from wrenn/wrenn

Replace template text input with searchable combobox, lock specs for snapshots

Template field is now a filterable dropdown that fetches available
templates on dialog open. Selecting a snapshot auto-fills and disables
vCPU/memory inputs since they must match the original capsule config.
This commit is contained in:
2026-04-11 07:00:59 +06:00
parent 11ca6935a6
commit 0807946d45

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { createCapsule, type Capsule, type CreateCapsuleParams } from '$lib/api/capsules'; import { onMount } from 'svelte';
import { createCapsule, listSnapshots, type Capsule, type CreateCapsuleParams, type Snapshot } from '$lib/api/capsules';
type Props = { type Props = {
open: boolean; open: boolean;
@ -12,12 +13,109 @@
let creating = $state(false); let creating = $state(false);
let createError = $state<string | null>(null); let createError = $state<string | null>(null);
// Template combobox state
let templates = $state<Snapshot[]>([]);
let templatesLoading = $state(false);
let templateQuery = $state('');
let comboOpen = $state(false);
let highlightIdx = $state(-1);
let inputEl = $state<HTMLInputElement | undefined>(undefined);
let listEl = $state<HTMLUListElement | undefined>(undefined);
// Snapshots have fixed CPU/RAM from the snapshot state — users cannot override.
let selectedIsSnapshot = $derived(
templates.find((t) => t.name === createForm.template)?.type === 'snapshot'
);
let filtered = $derived.by(() => {
const q = templateQuery.toLowerCase();
if (!q) return templates;
return templates.filter((t) => t.name.toLowerCase().includes(q));
});
// Fetch templates when dialog opens
$effect(() => {
if (open && templates.length === 0 && !templatesLoading) {
templatesLoading = true;
listSnapshots().then((result) => {
if (result.ok) templates = result.data;
templatesLoading = false;
});
}
if (open) {
templateQuery = createForm.template ?? '';
}
});
function selectTemplate(t: Snapshot) {
createForm.template = t.name;
templateQuery = t.name;
// Pre-fill specs from the template if available
if (t.vcpus) createForm.vcpus = t.vcpus;
if (t.memory_mb) createForm.memory_mb = t.memory_mb;
comboOpen = false;
highlightIdx = -1;
}
function handleInputKeydown(e: KeyboardEvent) {
if (!comboOpen && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
comboOpen = true;
highlightIdx = 0;
e.preventDefault();
return;
}
if (!comboOpen) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
highlightIdx = Math.min(highlightIdx + 1, filtered.length - 1);
scrollToHighlighted();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
highlightIdx = Math.max(highlightIdx - 1, 0);
scrollToHighlighted();
} else if (e.key === 'Enter' && highlightIdx >= 0 && highlightIdx < filtered.length) {
e.preventDefault();
selectTemplate(filtered[highlightIdx]);
} else if (e.key === 'Escape') {
comboOpen = false;
highlightIdx = -1;
}
}
function scrollToHighlighted() {
if (!listEl) return;
const item = listEl.children[highlightIdx] as HTMLElement | undefined;
item?.scrollIntoView({ block: 'nearest' });
}
function handleInputFocus() {
comboOpen = true;
highlightIdx = -1;
}
function handleInputBlur() {
// Delay to allow click on dropdown item to fire first
setTimeout(() => {
comboOpen = false;
// If the typed query matches an existing template, apply it
const match = templates.find((t) => t.name === templateQuery);
if (match) {
createForm.template = match.name;
} else {
// Allow free-form entry (user might know a template name not in the list)
createForm.template = templateQuery;
}
}, 150);
}
async function handleCreate() { async function handleCreate() {
creating = true; creating = true;
createError = null; createError = null;
const result = await createCapsule(createForm); const result = await createCapsule(createForm);
if (result.ok) { if (result.ok) {
createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 }; createForm = { template: 'minimal', vcpus: 1, memory_mb: 512, timeout_sec: 0 };
templateQuery = 'minimal';
oncreated?.(result.data); oncreated?.(result.data);
onclose(); onclose();
} else { } else {
@ -47,16 +145,98 @@
{/if} {/if}
<div class="mt-5 space-y-4"> <div class="mt-5 space-y-4">
<div> <!-- Template combobox -->
<div class="relative">
<label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-template">Template</label> <label class="mb-1.5 block text-label font-semibold uppercase tracking-[0.05em] text-[var(--color-text-tertiary)]" for="create-template">Template</label>
<input <div class="relative">
id="create-template" <input
type="text" bind:this={inputEl}
bind:value={createForm.template} id="create-template"
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)]" type="text"
placeholder="minimal" role="combobox"
/> aria-expanded={comboOpen}
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Name of a snapshot or base image to boot from.</p> aria-autocomplete="list"
aria-controls="template-listbox"
autocomplete="off"
bind:value={templateQuery}
onfocus={handleInputFocus}
onblur={handleInputBlur}
onkeydown={handleInputKeydown}
disabled={creating}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] py-2 pl-3 pr-8 font-mono text-ui text-[var(--color-text-bright)] outline-none placeholder:text-[var(--color-text-muted)] transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
placeholder="Search templates..."
/>
<!-- Chevron -->
<svg
class="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-[var(--color-text-muted)] transition-transform duration-150 {comboOpen ? 'rotate-180' : ''}"
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
<!-- Dropdown -->
{#if comboOpen}
<ul
bind:this={listEl}
id="template-listbox"
role="listbox"
class="absolute z-10 mt-1 max-h-[200px] w-full overflow-y-auto rounded-[var(--radius-input)] border border-[var(--color-border-mid)] bg-[var(--color-bg-3)] py-1 shadow-lg"
style="animation: fadeUp 0.12s ease both"
>
{#if templatesLoading}
<li class="flex items-center gap-2 px-3 py-2.5 text-meta text-[var(--color-text-muted)]">
<svg class="animate-spin" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg>
Loading templates...
</li>
{:else if filtered.length === 0}
<li class="px-3 py-2.5 text-meta text-[var(--color-text-muted)]">
{templateQuery ? 'No matching templates' : 'No templates available'}
</li>
{:else}
{#each filtered as t, i (t.name)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<li
role="option"
aria-selected={i === highlightIdx}
class="flex cursor-pointer items-center gap-2.5 px-3 py-2 transition-colors duration-75
{i === highlightIdx
? 'bg-[var(--color-bg-5)] text-[var(--color-text-bright)]'
: 'text-[var(--color-text-primary)] hover:bg-[var(--color-bg-4)]'}
{createForm.template === t.name ? 'font-medium' : ''}"
onmousedown={(e) => { e.preventDefault(); selectTemplate(t); }}
onmouseenter={() => { highlightIdx = i; }}
>
<!-- Type badge -->
{#if t.type === 'snapshot'}
<span class="inline-flex shrink-0 items-center rounded-full border border-[var(--color-accent)]/25 bg-[var(--color-accent)]/8 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-[var(--color-accent-bright)]">
snap
</span>
{:else}
<span class="inline-flex shrink-0 items-center rounded-full border border-[var(--color-blue)]/25 bg-[var(--color-blue)]/8 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.04em] text-[var(--color-blue)]">
base
</span>
{/if}
<span class="truncate font-mono text-meta">{t.name}</span>
<!-- Specs hint -->
{#if t.vcpus && t.memory_mb}
<span class="ml-auto shrink-0 text-[10px] text-[var(--color-text-muted)]">
{t.vcpus}v · {t.memory_mb}MB
</span>
{/if}
<!-- Selected check -->
{#if createForm.template === t.name}
<svg class="ml-auto shrink-0 text-[var(--color-accent)]" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</li>
{/each}
{/if}
</ul>
{/if}
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Snapshot or base image to boot from.</p>
</div> </div>
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
@ -68,7 +248,8 @@
min="1" min="1"
max="8" max="8"
bind:value={createForm.vcpus} bind:value={createForm.vcpus}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]" disabled={creating || selectedIsSnapshot}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/> />
</div> </div>
<div> <div>
@ -80,7 +261,8 @@
max="8192" max="8192"
step="128" step="128"
bind:value={createForm.memory_mb} bind:value={createForm.memory_mb}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]" disabled={creating || selectedIsSnapshot}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
/> />
</div> </div>
</div> </div>
@ -92,7 +274,8 @@
type="number" type="number"
min="0" min="0"
bind:value={createForm.timeout_sec} bind:value={createForm.timeout_sec}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)]" disabled={creating}
class="w-full rounded-[var(--radius-input)] border border-[var(--color-border)] bg-[var(--color-bg-4)] px-3 py-2 font-mono text-ui text-[var(--color-text-bright)] outline-none transition-colors duration-150 focus:border-[var(--color-accent)] disabled:opacity-60"
placeholder="0" placeholder="0"
/> />
<p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Seconds of inactivity before the capsule pauses. Set to 0 to keep it running indefinitely.</p> <p class="mt-1.5 text-meta text-[var(--color-text-muted)]">Seconds of inactivity before the capsule pauses. Set to 0 to keep it running indefinitely.</p>