410a131cec
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.
1137 lines
32 KiB
Svelte
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>
|