feat(electrobun): project wizard phases 1-5 (WIP)

- sanitize.ts: input sanitization (trim, control chars, path traversal)
- provider-scanner.ts: detect Claude/Codex/Ollama/Gemini availability
- model-fetcher.ts: live model lists from 4 provider APIs
- ModelConfigPanel.svelte: per-provider config (thinking, effort, sandbox, temperature)
- WizardStep1-3.svelte: split wizard into composable steps
- CustomDropdown/Checkbox/Radio: themed UI components
- provider-handlers.ts: provider.scan + provider.models RPC
- Wire providers into wizard step 3 (live detection + model lists)
- Replace native selects in 5 settings panels with CustomDropdown
This commit is contained in:
Hibryda 2026-03-23 13:05:07 +01:00
parent b7fc3a0f9b
commit d4014a193d
25 changed files with 2112 additions and 759 deletions

View file

@ -0,0 +1,149 @@
<script lang="ts">
import { PROJECT_ICONS, ACCENT_COLORS } from './wizard-icons.ts';
import {
Terminal, Server, Globe, Code, Database, Cpu, Zap, Shield,
Rocket, Bug, Puzzle, Box, Layers, GitBranch, Wifi, Lock,
FlaskConical, Sparkles, FileCode, Wrench, Folder, Bot, Cloud, HardDrive,
} from 'lucide-svelte';
interface Props {
projectName: string;
nameError: string;
selectedBranch: string;
branches: string[];
useWorktrees: boolean;
selectedGroupId: string;
groups: Array<{ id: string; name: string }>;
projectIcon: string;
projectColor: string;
shellChoice: string;
isGitRepo: boolean;
onUpdate: (field: string, value: unknown) => void;
}
let {
projectName, nameError, selectedBranch, branches, useWorktrees,
selectedGroupId, groups, projectIcon, projectColor, shellChoice,
isGitRepo, onUpdate,
}: Props = $props();
const SHELLS = ['bash', 'zsh', 'fish', 'sh'];
let branchDDOpen = $state(false);
let groupDDOpen = $state(false);
let shellDDOpen = $state(false);
function closeAllDD() { branchDDOpen = false; groupDDOpen = false; shellDDOpen = false; }
let branchLabel = $derived(selectedBranch || 'main');
let groupLabel = $derived(groups.find(g => g.id === selectedGroupId)?.name ?? 'Select');
const ICON_MAP: Record<string, typeof Terminal> = {
Terminal, Server, Globe, Code, Database, Cpu, Zap, Shield,
Rocket, Bug, Puzzle, Box, Layers, GitBranch, Wifi, Lock,
FlaskConical, Sparkles, FileCode, Wrench, Folder, Bot, Cloud, HardDrive,
};
</script>
<label class="wz-label">Project name</label>
<input class="wz-input" class:error={!!nameError} type="text"
value={projectName} oninput={(e) => onUpdate('projectName', (e.target as HTMLInputElement).value)}
placeholder="my-project" />
{#if nameError}<span class="wz-hint error">{nameError}</span>{/if}
{#if isGitRepo && branches.length > 0}
<label class="wz-label">Branch</label>
<div class="wz-dd" style="position:relative;">
<button class="wz-dd-btn" onclick={() => { closeAllDD(); branchDDOpen = !branchDDOpen; }}>
<span>{branchLabel}</span><span class="wz-dd-chev">&#9662;</span>
</button>
<div class="wz-dd-menu" style:display={branchDDOpen ? 'block' : 'none'}>
{#each branches as br}
<button class="wz-dd-item" class:active={selectedBranch === br}
onclick={() => { onUpdate('selectedBranch', br); branchDDOpen = false; }}>{br}</button>
{/each}
</div>
</div>
<label class="wz-toggle-row">
<input type="checkbox" checked={useWorktrees}
onchange={(e) => onUpdate('useWorktrees', (e.target as HTMLInputElement).checked)} />
<span>Use worktrees</span>
</label>
{/if}
<label class="wz-label">Group</label>
<div class="wz-dd" style="position:relative;">
<button class="wz-dd-btn" onclick={() => { closeAllDD(); groupDDOpen = !groupDDOpen; }}>
<span>{groupLabel}</span><span class="wz-dd-chev">&#9662;</span>
</button>
<div class="wz-dd-menu" style:display={groupDDOpen ? 'block' : 'none'}>
{#each groups as g}
<button class="wz-dd-item" class:active={selectedGroupId === g.id}
onclick={() => { onUpdate('selectedGroupId', g.id); groupDDOpen = false; }}>{g.name}</button>
{/each}
</div>
</div>
<label class="wz-label">Icon</label>
<div class="wz-icon-grid">
{#each PROJECT_ICONS as ic}
<button class="wz-icon-btn" class:selected={projectIcon === ic.name}
onclick={() => onUpdate('projectIcon', ic.name)} title={ic.label}>
{#if ICON_MAP[ic.name]}
<svelte:component this={ICON_MAP[ic.name]} size={16} />
{/if}
</button>
{/each}
</div>
<label class="wz-label">Color</label>
<div class="wz-color-grid">
{#each ACCENT_COLORS as c}
<button class="wz-color-dot" class:selected={projectColor === `var(${c.var})`}
style:background={`var(${c.var})`}
onclick={() => onUpdate('projectColor', `var(${c.var})`)}
title={c.name}></button>
{/each}
</div>
<label class="wz-label">Shell</label>
<div class="wz-dd" style="position:relative;">
<button class="wz-dd-btn" onclick={() => { closeAllDD(); shellDDOpen = !shellDDOpen; }}>
<span>{shellChoice}</span><span class="wz-dd-chev">&#9662;</span>
</button>
<div class="wz-dd-menu" style:display={shellDDOpen ? 'block' : 'none'}>
{#each SHELLS as sh}
<button class="wz-dd-item" class:active={shellChoice === sh}
onclick={() => { onUpdate('shellChoice', sh); shellDDOpen = false; }}>{sh}</button>
{/each}
</div>
</div>
<style>
.wz-label { font-size: 0.75rem; font-weight: 500; color: var(--ctp-subtext0); margin-top: 0.25rem; }
.wz-input { padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); width: 100%; }
.wz-input:focus { outline: none; border-color: var(--ctp-blue); }
.wz-input.error { border-color: var(--ctp-red); }
.wz-input::placeholder { color: var(--ctp-overlay0); }
.wz-hint { font-size: 0.6875rem; }
.wz-hint.error { color: var(--ctp-red); }
.wz-dd { width: 100%; }
.wz-dd-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 0.375rem 0.5rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); cursor: pointer; text-align: left; }
.wz-dd-btn:hover { border-color: var(--ctp-surface2); }
.wz-dd-chev { color: var(--ctp-overlay1); font-size: 0.75rem; }
.wz-dd-menu { position: absolute; top: 100%; left: 0; right: 0; z-index: 20; background: var(--ctp-mantle); border: 1px solid var(--ctp-surface1); border-radius: 0.25rem; margin-top: 0.125rem; max-height: 10rem; overflow-y: auto; box-shadow: 0 0.25rem 0.75rem color-mix(in srgb, var(--ctp-crust) 50%, transparent); }
.wz-dd-item { width: 100%; padding: 0.3125rem 0.5rem; background: none; border: none; color: var(--ctp-text); font-size: 0.8125rem; font-family: var(--ui-font-family); cursor: pointer; text-align: left; }
.wz-dd-item:hover { background: var(--ctp-surface0); }
.wz-dd-item.active { color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 10%, transparent); }
.wz-toggle-row { display: flex; align-items: center; gap: 0.5rem; font-size: 0.8125rem; color: var(--ctp-text); cursor: pointer; margin-top: 0.25rem; }
.wz-icon-grid { display: flex; flex-wrap: wrap; gap: 0.25rem; }
.wz-icon-btn { width: 2rem; height: 2rem; border-radius: 0.25rem; border: 1px solid var(--ctp-surface1); background: var(--ctp-surface0); cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--ctp-subtext0); }
.wz-icon-btn:hover { border-color: var(--ctp-overlay1); color: var(--ctp-text); }
.wz-icon-btn.selected { border-color: var(--ctp-blue); background: color-mix(in srgb, var(--ctp-blue) 15%, transparent); color: var(--ctp-blue); }
.wz-color-grid { display: flex; flex-wrap: wrap; gap: 0.375rem; }
.wz-color-dot { width: 1.5rem; height: 1.5rem; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color 0.12s, transform 0.12s; }
.wz-color-dot:hover { transform: scale(1.15); }
.wz-color-dot.selected { border-color: var(--ctp-text); box-shadow: 0 0 0 1px var(--ctp-surface0); }
input[type="checkbox"] { -webkit-appearance: none; appearance: none; width: 1rem; height: 1rem; border: 1px solid var(--ctp-surface2); border-radius: 0.1875rem; background: var(--ctp-surface0); cursor: pointer; position: relative; vertical-align: middle; flex-shrink: 0; }
input[type="checkbox"]:checked { background: var(--ctp-blue); border-color: var(--ctp-blue); }
input[type="checkbox"]:checked::after { content: ''; position: absolute; left: 0.25rem; top: 0.0625rem; width: 0.3125rem; height: 0.5625rem; border: solid var(--ctp-base); border-width: 0 2px 2px 0; transform: rotate(45deg); }
</style>