Files
dockhand-upstream/src/routes/stacks/ComposeValidatePanel.svelte
T

281 lines
11 KiB
Svelte

<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { CircleX, TriangleAlert, Info, CheckCircle2, ArrowRight, Loader2, RefreshCw, X, ChevronDown, Wand2 } from 'lucide-svelte';
import { slide } from 'svelte/transition';
import { findingKey, type QuickFix } from '$lib/utils/compose-quick-fix';
export interface ValidateFinding {
ruleId: string;
severity: 'error' | 'warn' | 'info';
message: string;
hint?: string;
service?: string;
line?: number;
fix?: QuickFix;
fixDescription?: string;
}
export interface ValidateReport {
findings: ValidateFinding[];
counts: { error: number; warn: number; info: number };
}
interface Props {
report: ValidateReport | null;
loading?: boolean;
error?: string | null;
activeLine?: number | null;
onClose: () => void;
onJumpToLine?: (line: number) => void;
onRevalidate?: () => void;
onApplyFix?: (finding: ValidateFinding) => void;
}
let { report, loading = false, error = null, activeLine = null, onClose, onJumpToLine, onRevalidate, onApplyFix }: Props = $props();
const SEV = {
error: { label: 'Errors', Icon: CircleX, color: 'var(--sev-error)', bg: 'rgba(239,68,68,0.12)' },
warn: { label: 'Warnings', Icon: TriangleAlert, color: 'var(--sev-warn)', bg: 'rgba(245,158,11,0.12)' },
info: { label: 'Suggestions', Icon: Info, color: 'var(--sev-info)', bg: 'rgba(59,130,246,0.12)' }
} as const;
const findings = $derived(report?.findings ?? []);
const counts = $derived(report?.counts ?? { error: 0, warn: 0, info: 0 });
const total = $derived(counts.error + counts.warn + counts.info);
const clean = $derived(!loading && !error && report !== null && total === 0);
const grouped = $derived(
(['error', 'warn', 'info'] as const)
.map((sev) => ({ sev, items: findings.filter((f) => f.severity === sev) }))
.filter((g) => g.items.length > 0)
);
// Collapsed severity groups (all expanded by default).
let collapsed = $state<Record<'error' | 'warn' | 'info', boolean>>({ error: false, warn: false, info: false });
function toggleGroup(sev: 'error' | 'warn' | 'info') {
collapsed = { ...collapsed, [sev]: !collapsed[sev] };
}
// Explicitly scroll the finding for a line into view (called by the parent when a
// gutter icon is clicked - NOT on every re-validate, which would make the list jump).
let bodyEl = $state<HTMLDivElement | null>(null);
export function scrollToFinding(line: number) {
const target = findings.find((f) => f.line === line);
if (target && collapsed[target.severity]) {
collapsed = { ...collapsed, [target.severity]: false };
}
requestAnimationFrame(() => {
bodyEl?.querySelector(`[data-line="${line}"]`)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
}
</script>
<div class="cvp">
<!-- Header: title + count chips inline -->
<div class="cvp-header">
<div class="cvp-title">Compose Validate</div>
{#if !loading && !error && report}
<div class="cvp-chips">
{#each ['error', 'warn', 'info'] as const as sev}
{@const c = counts[sev]}
{@const SIcon = SEV[sev].Icon}
<div class="cvp-chip" class:cvp-chip-muted={c === 0} style="--chip-color: {SEV[sev].color}; --chip-bg: {SEV[sev].bg}">
<SIcon class="h-3 w-3" />
<span class="cvp-chip-count">{c}</span>
</div>
{/each}
</div>
{/if}
<div class="cvp-header-actions">
{#if onRevalidate}
<button class="cvp-iconbtn" title="Re-check" onclick={onRevalidate} disabled={loading}>
{#if loading}<Loader2 class="h-4 w-4 animate-spin" />{:else}<RefreshCw class="h-4 w-4" />{/if}
</button>
{/if}
<button class="cvp-iconbtn" title="Close panel" onclick={onClose}><X class="h-4 w-4" /></button>
</div>
</div>
<!-- Body -->
<div class="cvp-body" bind:this={bodyEl}>
{#if loading}
<div class="cvp-center"><Loader2 class="h-5 w-5 animate-spin text-muted-foreground" /><p>Validating...</p></div>
{:else if error}
<div class="cvp-center cvp-error-state"><CircleX class="h-5 w-5" /><p>{error}</p></div>
{:else if clean}
<div class="cvp-center cvp-clean">
<div class="cvp-clean-badge"><CheckCircle2 class="h-7 w-7" /></div>
<p class="cvp-clean-title">No problems found</p>
<p class="cvp-clean-sub">Passes every enabled check.</p>
</div>
{:else}
{#each grouped as group (group.sev)}
{@const GIcon = SEV[group.sev].Icon}
<div class="cvp-group">
<button type="button" class="cvp-group-head" style="--chip-color: {SEV[group.sev].color}" onclick={() => toggleGroup(group.sev)}>
<ChevronDown class="cvp-chevron h-3.5 w-3.5 {collapsed[group.sev] ? '-rotate-90' : ''}" />
<GIcon class="h-3.5 w-3.5" />
<span>{SEV[group.sev].label}</span>
<span class="cvp-group-count">{group.items.length}</span>
</button>
{#if !collapsed[group.sev]}
{#each group.items as f (findingKey(f))}
<div
class="cvp-finding"
class:cvp-finding-active={activeLine != null && f.line === activeLine}
style="--chip-color: {SEV[f.severity].color}"
data-line={f.line ?? undefined}
transition:slide={{ duration: 200 }}
>
<div class="cvp-finding-bar"></div>
<div class="cvp-finding-body">
<div class="cvp-finding-top">
<span class="cvp-rule">{f.ruleId}</span>
{#if f.line}<span class="cvp-line">L{f.line}<ArrowRight class="h-3 w-3" /></span>{/if}
{#if f.fix && onApplyFix}
<button
type="button"
class="cvp-fix"
title={f.fixDescription ?? 'Apply quick fix'}
onclick={() => onApplyFix?.(f)}
>
<Wand2 class="h-3 w-3" />
Fix
</button>
{/if}
</div>
<button
type="button"
class="cvp-finding-jump"
onclick={() => f.line && onJumpToLine?.(f.line)}
disabled={!f.line}
>
<p class="cvp-message">{f.message}</p>
{#if f.hint}<p class="cvp-hint">{f.hint}</p>{/if}
</button>
</div>
</div>
{/each}
{/if}
</div>
{/each}
{/if}
</div>
</div>
<style>
.cvp {
--sev-error: #ef4444;
--sev-warn: #f59e0b;
--sev-info: #3b82f6;
display: flex;
flex-direction: column;
height: 100%;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 0.5rem;
overflow: hidden;
}
.cvp-header {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid hsl(var(--border));
}
.cvp-title { font-size: 0.82rem; font-weight: 650; line-height: 1.1; white-space: nowrap; }
.cvp-chips { display: flex; gap: 0.3rem; margin-left: 0.15rem; }
.cvp-header-actions { display: flex; gap: 0.15rem; margin-left: auto; }
.cvp-iconbtn {
display: flex; align-items: center; justify-content: center;
width: 1.6rem; height: 1.6rem; border-radius: 0.35rem;
color: hsl(var(--muted-foreground)); flex-shrink: 0;
transition: background 0.12s ease, color 0.12s ease;
}
.cvp-iconbtn:hover { background: hsl(var(--muted)); color: hsl(var(--foreground)); }
.cvp-iconbtn:disabled { opacity: 0.5; }
.cvp-chip {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.2rem 0.5rem; border-radius: 999px; font-size: 0.72rem;
color: var(--chip-color); background: var(--chip-bg);
border: 1px solid color-mix(in srgb, var(--chip-color) 30%, transparent);
}
.cvp-chip-muted { opacity: 0.4; filter: grayscale(0.6); }
.cvp-chip-count { font-weight: 700; font-variant-numeric: tabular-nums; }
.cvp-body { flex: 1; overflow-y: auto; padding: 0.5rem 0.6rem 0.75rem; }
.cvp-center {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.6rem; padding: 2.5rem 0.75rem; color: hsl(var(--muted-foreground));
text-align: center; font-size: 0.8rem;
}
.cvp-error-state { color: var(--sev-error); }
.cvp-clean-badge {
display: flex; align-items: center; justify-content: center;
width: 3rem; height: 3rem; border-radius: 999px;
background: rgba(34,197,94,0.14); color: #22c55e;
}
.cvp-clean-title { font-size: 0.9rem; font-weight: 600; color: hsl(var(--foreground)); }
.cvp-clean-sub { font-size: 0.76rem; }
.cvp-group { margin-top: 0.7rem; }
.cvp-group:first-child { margin-top: 0.15rem; }
.cvp-group-head {
display: flex; align-items: center; gap: 0.35rem; width: 100%;
font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
color: var(--chip-color); margin-bottom: 0.35rem;
padding: 0.15rem 0.2rem; border-radius: 0.3rem;
transition: background 0.12s ease;
}
.cvp-group-head:hover { background: hsl(var(--muted) / 0.5); }
:global(.cvp-chevron) { transition: transform 0.15s ease; flex-shrink: 0; opacity: 0.8; }
.cvp-group-count {
font-variant-numeric: tabular-nums;
background: color-mix(in srgb, var(--chip-color) 15%, transparent);
border-radius: 999px; padding: 0 0.35rem; font-size: 0.66rem;
}
.cvp-finding {
display: flex; align-items: stretch; width: 100%; text-align: left;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.45rem; margin-bottom: 0.35rem; overflow: hidden;
transition: border-color 0.12s ease, background 0.12s ease;
}
.cvp-finding:hover {
border-color: color-mix(in srgb, var(--chip-color) 55%, hsl(var(--border)));
}
.cvp-finding-active {
border-color: var(--chip-color) !important;
background: color-mix(in srgb, var(--chip-color) 8%, hsl(var(--background)));
}
.cvp-finding-bar { width: 3px; background: var(--chip-color); flex-shrink: 0; }
.cvp-finding-body { padding: 0.5rem 0.65rem; min-width: 0; flex: 1; display: flex; flex-direction: column; }
.cvp-finding-jump {
text-align: left; background: transparent; transition: transform 0.06s ease;
}
.cvp-finding-jump:not(:disabled):hover { cursor: pointer; }
.cvp-finding-jump:not(:disabled):active { transform: translateY(1px); }
.cvp-finding-jump:disabled { cursor: default; }
.cvp-fix {
display: inline-flex; align-items: center; gap: 0.2rem; flex-shrink: 0;
margin-left: 0.4rem; padding: 0.12rem 0.4rem; border-radius: 0.3rem;
font-size: 0.66rem; font-weight: 600;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted));
border: 1px solid hsl(var(--border));
transition: background 0.12s ease, color 0.12s ease;
}
.cvp-fix:hover { background: hsl(var(--accent)); color: hsl(var(--foreground)); cursor: pointer; }
.cvp-fix:active { transform: translateY(1px); }
.cvp-finding-top { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.2rem; }
.cvp-rule {
font-family: ui-monospace, monospace; font-size: 0.64rem; font-weight: 600;
color: var(--chip-color);
background: color-mix(in srgb, var(--chip-color) 12%, transparent);
padding: 0.03rem 0.35rem; border-radius: 0.28rem;
}
.cvp-line {
display: inline-flex; align-items: center; gap: 0.1rem;
font-size: 0.66rem; color: hsl(var(--muted-foreground)); margin-left: auto;
}
.cvp-message { font-size: 0.78rem; color: hsl(var(--foreground)); line-height: 1.3; }
.cvp-hint { font-size: 0.72rem; color: hsl(var(--muted-foreground)); margin-top: 0.15rem; line-height: 1.3; }
</style>