Files
tiny-forge/web/src/lib/components/workload/StaticDiscoveryWizard.svelte
T
alexei.dolgolyov 410a131cec feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
  WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
  ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
  + {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
  /apps/[id] edit form onto the same components (removes the duplication). Add
  vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
  environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
  state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
  conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
  label hints; dashboard + /apps "Total workloads" count only source_kind workloads
  (drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
  empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.

Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
2026-05-29 02:09:54 +03:00

1137 lines
32 KiB
Svelte

<!--
Git-discovery wizard shared by the Static and Dockerfile source forms.
Both sources clone a repo via the same provider/owner/repo/branch/token
path, so this component owns the discovery UX once: provider auto-detect,
test-connection probe, repo / branch pickers (EntityPicker mounts), and —
for the static source only — a collapsible folder-tree browser plus Deno
auto-detect.
The shared git slice is bound in via `git` (a `GitSourceState` from
`$lib/workload/sourceForms`). The static-only folder/mode controls are
opt-in: pass `showFolderTree` + `folderPath`/`mode` bindings to render
them. The dockerfile form omits those, so its render collapses to the
provider+repo+branch+token+test block.
Every discovery call is best-effort: failures show inline status and never
block the operator from filling fields by hand. AbortController is not used
here because the legacy inline handlers didn't use it for discovery — they
guard on `pending` flags + invalidate downstream caches instead, which we
preserve verbatim so behaviour is identical.
-->
<script lang="ts">
import type { GitSourceState } from '$lib/workload/sourceForms';
import type {
RepoInfo,
FolderEntry,
GitProviderKind,
DiscoveryGitRequest
} from '$lib/api';
import type { EntityPickerItem } from '$lib/types';
import * as api from '$lib/api';
import EntityPicker from '$lib/components/EntityPicker.svelte';
import { IconSearch, IconLoader, IconCheck, IconChevronRight, IconX } from '$lib/components/icons';
import { t } from '$lib/i18n';
interface Props {
/** Shared git slice (provider/baseURL/repoOwner/repoName/branch/accessToken). */
git: GitSourceState;
/**
* Layout variant. `static` renders the wide labelled buttons + the
* folder tree; `dockerfile` renders the compact two-column layout the
* dockerfile form used. Defaults to `static`.
*/
variant?: 'static' | 'dockerfile';
/** Render the folder-tree browser + folder input (static only). */
showFolderTree?: boolean;
/** Bound static folder path (only when showFolderTree). */
folderPath?: string;
/** Bound static mode — drives Deno auto-detect (only when showFolderTree). */
mode?: 'static' | 'deno';
/** Set true once the user manually flips the mode radio (only static). */
modeUserOverride?: boolean;
/**
* Out-param: reflects whether a folder tree has been loaded. The static
* form gates its "auto-detected Deno" hint on this so the hint only
* appears after a real tree probe (matching the legacy
* `staticTree.length > 0` guard).
*/
treeLoaded?: boolean;
/**
* Transient discovery status. These are normally owned by the wizard's
* own internal `$state` (the defaults below), but the create wizard
* (`apps/new`) binds them up to the PAGE so the loaded tree + detect/
* test pills SURVIVE the source form unmounting under the Advanced-JSON
* / source-kind toggles. Optional + internal-fallback by design: the
* detail/edit page (`apps/[id]`) binds none of them and keeps the
* transient-resets-on-remount behaviour unchanged.
*/
tree?: FolderEntry[];
detectStatus?: 'idle' | 'pending' | 'ok' | 'error';
detectError?: string;
testStatus?: 'idle' | 'pending' | 'ok' | 'error';
testError?: string;
/** id prefix so labels stay unique if two ever mount. */
idPrefix?: string;
}
let {
git = $bindable(),
variant = 'static',
showFolderTree = false,
folderPath = $bindable(''),
mode = $bindable('static'),
modeUserOverride = $bindable(false),
treeLoaded = $bindable(false),
tree = $bindable([]),
detectStatus = $bindable('idle'),
detectError = $bindable(''),
testStatus = $bindable('idle'),
testError = $bindable(''),
idPrefix = 'disc'
}: Props = $props();
// ── Discovery state ───────────────────────────────────────────────
// Note: detectStatus/detectError/testStatus/testError/tree are bindable
// props (declared above) so a parent can hoist them to survive unmount;
// they default to the wizard's own internal state when unbound.
let detectedProvider = $state<GitProviderKind>('');
let showRepoPicker = $state(false);
let repoItems = $state<EntityPickerItem[]>([]);
let repoLoading = $state(false);
let repoError = $state('');
let showBranchPicker = $state(false);
let branchItems = $state<EntityPickerItem[]>([]);
let branchLoading = $state(false);
let branchError = $state('');
let treeOpen = $state(false);
let treeLoading = $state(false);
let treeError = $state('');
let expandedDirs = $state<Set<string>>(new Set());
// Mirror tree-loaded state out to the parent so the static form can gate
// its "auto-detected Deno" hint on a real tree probe (matching the legacy
// `staticTree.length > 0` guard).
$effect(() => {
treeLoaded = tree.length > 0;
});
// Min fields required before discovery calls are safe to fire.
const discoveryReady = $derived(
git.baseURL.trim() !== '' && git.repoOwner.trim() !== '' && git.repoName.trim() !== ''
);
function buildDiscoveryRequest(): DiscoveryGitRequest {
return {
provider: git.provider,
base_url: git.baseURL.trim(),
access_token: git.accessToken || undefined,
repo_owner: git.repoOwner.trim(),
repo_name: git.repoName.trim(),
branch: git.branch || undefined
};
}
async function detectProvider() {
if (!git.baseURL.trim()) return;
// Guard against rapid double-clicks: the disabled state lags the
// click handler by a microtask, so check explicitly.
if (detectStatus === 'pending') return;
detectStatus = 'pending';
detectError = '';
try {
// The backend returns DetectedGitProvider which is the narrow
// 'gitea' | 'github' | 'gitlab' union — assignment is direct.
const res = await api.detectGitProvider(git.baseURL.trim());
detectedProvider = res.provider;
git.provider = res.provider;
detectStatus = 'ok';
} catch {
// Discovery errors are surfaced as a friendly, localized message —
// the backend deliberately returns a generic, non-localized string
// (it must not echo the upstream provider's body, which can reflect
// the access token). The real cause is logged server-side.
detectError = $t('apps.new.errors.detectionFailed');
detectStatus = 'error';
}
}
async function testConnection() {
if (!discoveryReady) return;
if (testStatus === 'pending') return;
testStatus = 'pending';
testError = '';
try {
await api.testGitConnection(buildDiscoveryRequest());
testStatus = 'ok';
} catch {
testError = $t('apps.new.errors.connectionFailed');
testStatus = 'error';
}
}
async function openRepoPicker() {
if (!git.baseURL.trim()) return;
if (repoLoading) return;
showRepoPicker = true;
repoLoading = true;
repoError = '';
try {
const repos: RepoInfo[] = await api.listGitRepos(buildDiscoveryRequest());
repoItems = repos.map((r) => ({
value: JSON.stringify({ owner: r.owner, name: r.name }),
label: r.full_name,
description: r.description || undefined,
icon: r.private ? 'lock' : undefined
}));
} catch (e) {
// Surface the failure so an empty picker is not mistaken
// for "no repos found" — the operator typically needs to
// fix the base URL or access token to recover.
repoItems = [];
repoError = $t('apps.new.errors.reposFailed');
} finally {
repoLoading = false;
}
}
function selectRepo(value: string) {
try {
const parsed = JSON.parse(value) as { owner: string; name: string };
git.repoOwner = parsed.owner;
git.repoName = parsed.name;
} catch {
// best-effort; leave fields untouched if value isn't JSON
}
showRepoPicker = false;
// Repo changed → invalidate downstream caches.
branchItems = [];
tree = [];
expandedDirs = new Set();
testStatus = 'idle';
}
async function openBranchPicker() {
if (!discoveryReady) return;
if (branchLoading) return;
showBranchPicker = true;
branchLoading = true;
branchError = '';
try {
const branches: string[] = await api.listGitBranches(buildDiscoveryRequest());
branchItems = branches.map((b) => ({
value: b,
label: b
}));
// Auto-pick main/master if no branch is set yet.
if (!git.branch || git.branch === 'main') {
const preferred = branches.find((b) => b === 'main') ?? branches.find((b) => b === 'master');
if (preferred) git.branch = preferred;
}
} catch (e) {
branchItems = [];
branchError = $t('apps.new.errors.branchesFailed');
} finally {
branchLoading = false;
}
}
function selectBranch(value: string) {
git.branch = value;
showBranchPicker = false;
// Branch changed → invalidate tree cache.
tree = [];
expandedDirs = new Set();
}
async function toggleTree() {
treeOpen = !treeOpen;
if (treeOpen && tree.length === 0 && discoveryReady) {
await loadTree();
}
}
async function loadTree() {
treeLoading = true;
treeError = '';
try {
tree = await api.listGitTree(buildDiscoveryRequest());
autoDetectDenoMode();
} catch {
treeError = $t('apps.new.errors.treeFailed');
tree = [];
} finally {
treeLoading = false;
}
}
// Detect a Deno-style site by looking for an `api/` folder under
// the currently selected folder root. Skipped once the user has
// manually flipped the mode radio.
function autoDetectDenoMode() {
if (modeUserOverride) return;
const prefix = folderPath ? folderPath + '/api' : 'api';
const hasApi = tree.some(
(e) => e.is_dir && (e.path === prefix || e.path.startsWith(prefix + '/'))
);
if (hasApi) mode = 'deno';
}
// Tree helpers — ported from the legacy /sites/new wizard.
const folders = $derived(
tree.filter((e) => e.is_dir).sort((a, b) => a.path.localeCompare(b.path))
);
function getTopLevelFolders(): FolderEntry[] {
return folders.filter((f) => !f.path.includes('/'));
}
function getChildFolders(parentPath: string): FolderEntry[] {
return folders.filter((f) => {
if (!f.path.startsWith(parentPath + '/')) return false;
const rest = f.path.slice(parentPath.length + 1);
return !rest.includes('/');
});
}
function toggleDir(path: string) {
const next = new Set(expandedDirs);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
}
expandedDirs = next;
}
function selectFolder(path: string) {
folderPath = path;
// Re-run auto-detect against the new selection.
autoDetectDenoMode();
}
</script>
{#if variant === 'static'}
<div class="row">
<label class="sub" for="{idPrefix}-provider">
<span class="sub-label">{$t('apps.new.staticProvider')}</span>
<div class="input-with-button">
<select id="{idPrefix}-provider" class="input" bind:value={git.provider}>
<option value="gitea">gitea</option>
<option value="github">github</option>
<option value="gitlab">gitlab</option>
</select>
<button
type="button"
class="discover-btn"
onclick={detectProvider}
disabled={!git.baseURL.trim() || detectStatus === 'pending'}
title={$t('apps.new.staticDetectProvider')}
>
{#if detectStatus === 'pending'}
<IconLoader size={14} />
{:else}
<IconSearch size={14} />
{/if}
<span class="discover-btn-label">{$t('apps.new.staticDetectProvider')}</span>
</button>
</div>
</label>
<label class="sub" for="{idPrefix}-base-url">
<span class="sub-label">{$t('apps.new.staticBaseUrl')}</span>
<input
id="{idPrefix}-base-url"
type="url"
class="input mono"
bind:value={git.baseURL}
placeholder={$t('apps.new.staticBaseUrlPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
{#if detectStatus === 'ok'}
<div class="discover-pill discover-pill-ok">
<IconCheck size={12} />
<span>{$t('apps.new.staticDetectedOk', { provider: detectedProvider || git.provider })}</span>
</div>
{:else if detectStatus === 'error'}
<div class="discover-pill discover-pill-bad">
<IconX size={12} />
<span>{$t('apps.new.staticDetectedFailed', { error: detectError })}</span>
</div>
{/if}
<div class="row">
<label class="sub" for="{idPrefix}-owner">
<span class="sub-label"
>{$t('apps.new.staticRepoOwner')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}
>*</span
></span
>
<input
id="{idPrefix}-owner"
type="text"
class="input mono"
bind:value={git.repoOwner}
placeholder={$t('apps.new.staticRepoOwnerPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
</label>
<label class="sub" for="{idPrefix}-name">
<span class="sub-label"
>{$t('apps.new.staticRepoName')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}
>*</span
></span
>
<div class="input-with-button">
<input
id="{idPrefix}-name"
type="text"
class="input mono"
bind:value={git.repoName}
placeholder={$t('apps.new.staticRepoNamePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<button
type="button"
class="discover-icon-btn"
onclick={openRepoPicker}
disabled={!git.baseURL.trim() || repoLoading}
title={$t('apps.new.staticBrowseRepos')}
aria-label={$t('apps.new.staticBrowseRepos')}
>
{#if repoLoading}
<IconLoader size={14} />
{:else}
<IconSearch size={14} />
{/if}
</button>
</div>
{#if repoError}
<span class="discover-pill discover-pill-bad inline">{repoError}</span>
{/if}
</label>
</div>
<div class="row">
<label class="sub" for="{idPrefix}-branch">
<span class="sub-label">{$t('apps.new.staticBranch')}</span>
<div class="input-with-button">
<input
id="{idPrefix}-branch"
type="text"
class="input mono"
bind:value={git.branch}
placeholder={$t('apps.new.staticBranchPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
<button
type="button"
class="discover-icon-btn"
onclick={openBranchPicker}
disabled={!discoveryReady || branchLoading}
title={$t('apps.new.staticBrowseBranches')}
aria-label={$t('apps.new.staticBrowseBranches')}
>
{#if branchLoading}
<IconLoader size={14} />
{:else}
<IconSearch size={14} />
{/if}
</button>
</div>
{#if branchError}
<span class="discover-pill discover-pill-bad inline">{branchError}</span>
{/if}
</label>
{#if showFolderTree}
<label class="sub" for="{idPrefix}-folder">
<span class="sub-label">{$t('apps.new.staticFolder')}</span>
<input
id="{idPrefix}-folder"
type="text"
class="input mono"
bind:value={folderPath}
placeholder={$t('apps.new.staticFolderPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
{/if}
</div>
{#if showFolderTree}
<div class="tree-toggle-row">
<button
type="button"
class="editor-chip"
class:active={treeOpen}
onclick={toggleTree}
disabled={!discoveryReady}
title={$t('apps.new.staticBrowseFolders')}
aria-expanded={treeOpen}
aria-controls="{idPrefix}-tree-panel"
>
<IconChevronRight size={12} class={treeOpen ? 'tree-chev open' : 'tree-chev'} />
<span>{$t('apps.new.staticBrowseFolders')}</span>
</button>
{#if folderPath}
<span class="tree-selected mono">
{$t('apps.new.staticFolderSelectedPrefix')} <strong>{folderPath}</strong>
</span>
{/if}
</div>
{#if treeOpen}
<div class="tree-panel" id="{idPrefix}-tree-panel">
{#if treeLoading}
<div class="tree-loading">
<IconLoader size={14} />
<span>{$t('apps.new.staticTreeLoading')}</span>
</div>
{:else if treeError}
<div class="discover-pill discover-pill-bad">
<IconX size={12} />
<span>{treeError}</span>
</div>
{:else if folders.length === 0}
<div class="tree-empty">{$t('apps.new.staticTreeEmpty')}</div>
{:else}
<button
type="button"
class="tree-row tree-root"
class:tree-row-selected={folderPath === ''}
onclick={() => selectFolder('')}
>
<span class="tree-indent"></span>
<span class="tree-label mono">{$t('apps.new.staticFolderRoot')}</span>
</button>
{#each getTopLevelFolders() as folder (folder.path)}
{@const isSelected = folderPath === folder.path}
{@const isExpanded = expandedDirs.has(folder.path)}
{@const children = getChildFolders(folder.path)}
<div class="tree-branch">
<div class="tree-row-wrap">
{#if children.length > 0}
<button
type="button"
class="tree-chev-btn"
onclick={() => toggleDir(folder.path)}
aria-label={$t('common.toggle')}
>
<IconChevronRight size={12} class={isExpanded ? 'tree-chev open' : 'tree-chev'} />
</button>
{:else}
<span class="tree-chev-btn tree-chev-empty"></span>
{/if}
<button
type="button"
class="tree-row tree-row-leaf"
class:tree-row-selected={isSelected}
onclick={() => selectFolder(folder.path)}
>
<span class="tree-label mono">{folder.path}</span>
</button>
</div>
{#if isExpanded}
<div class="tree-children">
{#each children as child (child.path)}
{@const childSelected = folderPath === child.path}
<button
type="button"
class="tree-row tree-row-child"
class:tree-row-selected={childSelected}
onclick={() => selectFolder(child.path)}
>
<span class="tree-label mono">{child.path.split('/').pop()}</span>
</button>
{/each}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
<label class="sub" for="{idPrefix}-token">
<span class="sub-label">{$t('apps.new.staticToken')}</span>
<input
id="{idPrefix}-token"
type="password"
class="input"
bind:value={git.accessToken}
placeholder={$t('apps.new.staticTokenPlaceholder')}
autocomplete="new-password"
/>
<p class="hint">{$t('apps.new.staticTokenHint')}</p>
<div class="test-row">
<button
type="button"
class="discover-btn"
onclick={testConnection}
disabled={!discoveryReady || testStatus === 'pending'}
title={$t('apps.new.staticTestConnection')}
>
{#if testStatus === 'pending'}
<IconLoader size={14} />
{:else if testStatus === 'ok'}
<IconCheck size={14} />
{:else}
<IconSearch size={14} />
{/if}
<span class="discover-btn-label">{$t('apps.new.staticTestConnection')}</span>
</button>
{#if testStatus === 'ok'}
<span class="discover-pill discover-pill-ok inline">
<IconCheck size={12} />
<span>{$t('apps.new.staticConnectionOk')}</span>
</span>
{:else if testStatus === 'error'}
<span class="discover-pill discover-pill-bad inline">
<IconX size={12} />
<span>{$t('apps.new.staticConnectionFailed', { error: testError })}</span>
</span>
{/if}
</div>
</label>
{/if}
{:else}
<!-- dockerfile variant: compact two-column layout, no folder tree. -->
<div class="row">
<label class="sub" for="{idPrefix}-provider">
<span class="sub-label">{$t('apps.new.staticProvider')}</span>
<div class="input-with-button">
<select id="{idPrefix}-provider" class="input" bind:value={git.provider}>
<option value="gitea">gitea</option>
<option value="github">github</option>
<option value="gitlab">gitlab</option>
</select>
<button
type="button"
class="discover-btn"
onclick={detectProvider}
disabled={!git.baseURL.trim() || detectStatus === 'pending'}
title={$t('apps.new.staticDetectProvider')}
>
{#if detectStatus === 'pending'}
<IconLoader size={14} />
{:else}
<IconSearch size={14} />
{/if}
<span class="discover-btn-label">{$t('apps.new.staticDetectProvider')}</span>
</button>
</div>
</label>
<label class="sub" for="{idPrefix}-base-url">
<span class="sub-label">{$t('apps.new.staticBaseUrl')}</span>
<input
id="{idPrefix}-base-url"
type="url"
class="input mono"
bind:value={git.baseURL}
placeholder={$t('apps.new.staticBaseUrlPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
{#if detectStatus === 'ok'}
<div class="discover-pill discover-pill-ok">
<IconCheck size={12} />
<span>{$t('apps.new.staticDetectedOk', { provider: detectedProvider || git.provider })}</span>
</div>
{:else if detectStatus === 'error'}
<div class="discover-pill discover-pill-bad">
<IconX size={12} />
<span>{$t('apps.new.staticDetectedFailed', { error: detectError })}</span>
</div>
{/if}
<div class="row">
<label class="sub" for="{idPrefix}-owner">
<span class="sub-label"
>{$t('apps.new.staticRepoOwner')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}
>*</span
></span
>
<input
id="{idPrefix}-owner"
type="text"
class="input mono"
bind:value={git.repoOwner}
placeholder={$t('apps.new.staticRepoOwnerPlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
</label>
<label class="sub" for="{idPrefix}-name">
<span class="sub-label"
>{$t('apps.new.staticRepoName')}<span class="req-star" aria-label={$t('apps.new.fieldRequired')}
>*</span
></span
>
<div class="input-with-button">
<input
id="{idPrefix}-name"
type="text"
class="input mono"
bind:value={git.repoName}
placeholder={$t('apps.new.staticRepoNamePlaceholder')}
autocomplete="off"
spellcheck="false"
required
/>
<button
type="button"
class="discover-btn"
onclick={openRepoPicker}
disabled={!git.baseURL.trim() || repoLoading}
title={$t('apps.new.staticPickerRepoTitle')}
>
{#if repoLoading}
<IconLoader size={14} />
{:else}
<IconSearch size={14} />
{/if}
<span class="discover-btn-label">{$t('apps.new.staticBrowseRepos')}</span>
</button>
</div>
</label>
</div>
<div class="row">
<label class="sub" for="{idPrefix}-branch">
<span class="sub-label">{$t('apps.new.staticBranch')}</span>
<div class="input-with-button">
<input
id="{idPrefix}-branch"
type="text"
class="input mono"
bind:value={git.branch}
placeholder="main"
autocomplete="off"
spellcheck="false"
/>
<button
type="button"
class="discover-btn"
onclick={openBranchPicker}
disabled={!discoveryReady || branchLoading}
title={$t('apps.new.staticPickerBranchTitle')}
>
{#if branchLoading}
<IconLoader size={14} />
{:else}
<IconChevronRight size={14} />
{/if}
<span class="discover-btn-label">{$t('apps.new.staticBrowseBranches')}</span>
</button>
</div>
</label>
<label class="sub" for="{idPrefix}-token">
<span class="sub-label">{$t('apps.new.staticToken')}</span>
<input
id="{idPrefix}-token"
type="password"
class="input mono"
bind:value={git.accessToken}
placeholder={$t('apps.new.staticTokenPlaceholder')}
autocomplete="off"
spellcheck="false"
/>
</label>
</div>
<div class="row">
<button
type="button"
class="discover-btn"
onclick={testConnection}
disabled={!discoveryReady || testStatus === 'pending'}
title={$t('apps.new.staticTestConnection')}
>
{#if testStatus === 'pending'}
<IconLoader size={14} />
{:else}
<IconCheck size={14} />
{/if}
<span class="discover-btn-label">{$t('apps.new.staticTestConnection')}</span>
</button>
{#if testStatus === 'ok'}
<div class="discover-pill discover-pill-ok">
<IconCheck size={12} />
<span>{$t('apps.new.staticConnectionOk')}</span>
</div>
{:else if testStatus === 'error'}
<div class="discover-pill discover-pill-bad">
<IconX size={12} />
<span>{$t('apps.new.staticConnectionFailed', { error: testError })}</span>
</div>
{/if}
</div>
{/if}
<!-- Shared repo + branch pickers — one mount each regardless of variant.
The picker bodies only render when their `open` flag is true, so this
is invisible at rest. -->
<EntityPicker
bind:open={showRepoPicker}
items={repoItems}
current={git.repoOwner && git.repoName
? JSON.stringify({ owner: git.repoOwner, name: git.repoName })
: ''}
title={$t('apps.new.staticPickerRepoTitle')}
placeholder={$t('apps.new.staticPickerRepoPlaceholder')}
onselect={selectRepo}
onclose={() => (showRepoPicker = false)}
/>
<EntityPicker
bind:open={showBranchPicker}
items={branchItems}
current={git.branch}
title={$t('apps.new.staticPickerBranchTitle')}
placeholder={$t('apps.new.staticPickerBranchPlaceholder')}
onselect={selectBranch}
onclose={() => (showBranchPicker = false)}
/>
<style>
.input {
width: 100%;
background: var(--surface-input);
border: 1px solid var(--border-input);
border-radius: var(--radius-lg);
padding: 0.6rem 0.8rem;
font-size: 0.92rem;
color: var(--text-primary);
font-family: inherit;
outline: none;
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.input:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input:focus-visible {
outline: none;
}
.hint {
font-size: 0.78rem;
color: var(--text-tertiary);
margin: 0;
line-height: 1.45;
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.9rem;
}
@media (max-width: 600px) {
.row {
grid-template-columns: 1fr;
}
}
.sub {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.sub-label {
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* Required-field marker — same danger hue as the page-level `.req`
badge, rendered as a compact asterisk. */
.req-star {
margin-left: 0.2rem;
color: var(--color-danger);
font-weight: 700;
}
.editor-chip {
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
padding: 0.22rem 0.55rem;
font-family: var(--forge-mono);
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms ease;
}
.editor-chip:hover {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.editor-chip.active {
border-color: var(--forge-accent);
background: var(--forge-accent);
color: var(--surface-card);
}
.editor-chip.active:hover {
background: var(--forge-accent);
color: var(--surface-card);
}
.editor-chip:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
/* ── Static discovery controls ───────────────────── */
.input-with-button {
display: flex;
align-items: stretch;
gap: 0.4rem;
}
.input-with-button > .input,
.input-with-button > select.input {
flex: 1;
min-width: 0;
}
.discover-btn {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0 0.7rem;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
font-family: var(--forge-mono);
font-size: 0.6rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms ease;
white-space: nowrap;
}
.discover-btn:hover:not(:disabled) {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.discover-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.discover-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
.discover-btn-label {
line-height: 1;
}
.discover-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
background: var(--surface-card);
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms ease;
}
.discover-icon-btn:hover:not(:disabled) {
border-color: var(--color-brand-400);
color: var(--text-primary);
background: var(--surface-card-hover);
}
.discover-icon-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.discover-icon-btn:focus-visible {
outline: 2px solid var(--border-focus);
outline-offset: 2px;
}
/* Status pills — green/red mirroring .foot-status. */
.discover-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.28rem 0.55rem;
border-radius: var(--radius-md);
font-family: var(--forge-mono);
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.06em;
line-height: 1;
align-self: flex-start;
}
.discover-pill.inline {
align-self: center;
}
.discover-pill-ok {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success-dark);
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
}
.discover-pill-bad {
background: color-mix(in srgb, var(--color-danger) 14%, transparent);
color: var(--color-danger-dark);
border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
}
:global([data-theme='dark']) .discover-pill-ok {
color: #86efac;
}
:global([data-theme='dark']) .discover-pill-bad {
color: #fca5a5;
}
.test-row {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
/* Folder tree picker — inline collapsible panel below the folder_path
input. Compact monospaced rows; chevron rotates 90deg when expanded. */
.tree-toggle-row {
display: flex;
align-items: center;
gap: 0.55rem;
flex-wrap: wrap;
}
.tree-toggle-row .editor-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.tree-selected {
font-size: 0.7rem;
color: var(--text-tertiary);
letter-spacing: 0.02em;
}
.tree-selected strong {
color: var(--text-primary);
}
:global(.tree-chev) {
transition: transform 120ms ease;
}
:global(.tree-chev.open) {
transform: rotate(90deg);
}
.tree-panel {
border: 1px solid var(--border-primary);
border-radius: var(--radius-md);
background: var(--surface-card);
padding: 0.45rem;
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.tree-loading,
.tree-empty {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.6rem 0.5rem;
font-family: var(--forge-mono);
font-size: 0.7rem;
color: var(--text-tertiary);
letter-spacing: 0.04em;
}
.tree-row {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.32rem 0.5rem;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
text-align: left;
font-size: 0.78rem;
color: var(--text-secondary);
cursor: pointer;
transition: background 100ms ease, border-color 100ms ease, color 100ms ease;
}
.tree-row:hover {
background: var(--surface-card-hover);
color: var(--text-primary);
}
.tree-row-selected {
background: var(--forge-accent-soft);
border-color: var(--forge-accent);
color: var(--text-primary);
}
.tree-row-leaf {
flex: 1;
}
.tree-row-child {
margin-left: 1.4rem;
}
.tree-root {
border-bottom: 1px dashed var(--border-primary);
border-radius: 0;
margin-bottom: 0.25rem;
}
.tree-root.tree-row-selected {
border-bottom-color: var(--forge-accent);
}
.tree-indent {
display: inline-block;
width: 18px;
}
.tree-row-wrap {
display: flex;
align-items: center;
gap: 0.25rem;
}
.tree-chev-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background: transparent;
border: 0;
color: var(--text-tertiary);
cursor: pointer;
border-radius: var(--radius-sm);
}
.tree-chev-btn:hover {
color: var(--text-primary);
background: var(--surface-card-hover);
}
.tree-chev-empty {
cursor: default;
pointer-events: none;
}
.tree-branch {
display: flex;
flex-direction: column;
gap: 0.05rem;
}
.tree-children {
display: flex;
flex-direction: column;
gap: 0.05rem;
padding-left: 0;
}
.tree-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>