This commit is contained in:
jarek committed 2026-07-11 15:31:52 +02:00
1 parent 685132ff89
commit c718ac9a84
101 files changed
+5097 -642

No files matched your search

@@ -0,0 +1,176 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { Button } from '$lib/components/ui/button';
import { CircleArrowUp, Check, XCircle } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { appendEnvParam } from '$lib/stores/environment';
import { watchJob } from '$lib/utils/sse-fetch';
export interface UpdateCheckResultItem {
containerId: string;
containerName: string;
imageName: string;
}
export interface FailedCheckItem {
containerName: string;
imageName: string;
error: string;
}
interface Props {
envId: number | null;
/** When true and no check is running, the button reflects the "updates found" (green) state — e.g. from persisted pending updates loaded on mount. */
hasPendingUpdates?: boolean;
/** Called when a check finishes with the containers that have updates and any failures. */
onComplete?: (result: { withUpdates: UpdateCheckResultItem[]; failed: FailedCheckItem[] }) => void;
}
let { envId, hasPendingUpdates = false, onComplete }: Props = $props();
type Status = 'idle' | 'checking' | 'found' | 'none' | 'error';
let status = $state<Status>('idle');
let progress = $state({ checked: 0, total: 0 });
let btnEl = $state<HTMLButtonElement | null>(null);
let errorTimeout: ReturnType<typeof setTimeout> | null = null;
function unlockWidth() {
if (btnEl) btnEl.style.minWidth = '';
}
// Reset the button state when the environment changes — a "Latest" result
// from one environment must not linger on another.
let lastEnvId = $state<number | null>(null);
$effect(() => {
if (envId !== lastEnvId) {
lastEnvId = envId;
unlockWidth();
status = 'idle';
}
});
// Exposed so parents can reset after a related action (e.g. batch update).
export function reset() {
unlockWidth();
status = 'idle';
}
onDestroy(() => {
if (errorTimeout) clearTimeout(errorTimeout);
});
// When idle, reflect persisted pending updates as the "found" (green) state.
const displayStatus = $derived(status === 'idle' && hasPendingUpdates ? 'found' : status);
function showFailedChecksToast(failed: FailedCheckItem[], prefix: string) {
const details = failed.map((f) => `• ${f.containerName}: ${f.error}`).join('\n');
toast.warning(`${prefix} (${failed.length} failed to check)`, {
description: details,
descriptionClass: 'whitespace-pre-line',
class: '!w-[28rem] !max-w-[28rem]',
duration: Infinity,
action: { label: 'OK', onClick: () => {} }
});
}
async function checkForUpdates() {
status = 'checking';
progress = { checked: 0, total: 0 };
// Lock button width to prevent layout shift
if (btnEl) btnEl.style.minWidth = `${btnEl.offsetWidth}px`;
const failError = () => {
status = 'error';
if (errorTimeout) clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => { status = 'idle'; }, 3000);
unlockWidth();
};
try {
const response = await fetch(appendEnvParam('/api/containers/check-updates', envId), {
method: 'POST'
});
if (!response.ok) {
failError();
return;
}
const { jobId } = await response.json();
const data: any = await watchJob(jobId, (line) => {
if (line.event === 'progress') {
progress = line.data as { checked: number; total: number };
}
});
// A failed/malformed job resolves without a results array — treat as error
// rather than silently reporting "all up to date".
if (!data || !Array.isArray(data.results)) {
failError();
return;
}
unlockWidth();
const withUpdates: UpdateCheckResultItem[] = data.results
.filter((r: any) => r.hasUpdate && !r.systemContainer && !r.updateDisabled && !r.isLocalImage)
.map((r: any) => ({ containerId: r.containerId, containerName: r.containerName, imageName: r.imageName }));
const failed: FailedCheckItem[] = data.results
.filter((r: any) => r.error && !r.hasUpdate)
.map((r: any) => ({ containerName: r.containerName, imageName: r.imageName, error: r.error }));
if (withUpdates.length === 0) {
// Keep the "Latest" status until re-check / env-switch — don't auto-revert (#1019)
status = 'none';
if (failed.length > 0) {
showFailedChecksToast(failed, 'All containers are up to date');
} else {
toast.success('All containers are up to date');
}
} else {
status = 'found';
if (failed.length > 0) {
showFailedChecksToast(failed, `${withUpdates.length} update(s) available`);
} else {
toast.info(`${withUpdates.length} update(s) available`);
}
}
onComplete?.({ withUpdates, failed });
} catch {
failError();
}
}
</script>
<Button
bind:ref={btnEl}
size="sm"
variant="outline"
onclick={checkForUpdates}
disabled={status === 'checking'}
title="Check for available updates"
class="relative overflow-hidden"
>
{#if displayStatus === 'checking'}
<CircleArrowUp class="w-3.5 h-3.5 animate-spin" />
{#if progress.total > 0}
<span class="tabular-nums">Checking {String(progress.checked).padStart(String(progress.total).length, ' ')}/{progress.total}</span>
<div
class="absolute bottom-0 left-0 h-px bg-foreground transition-[width] duration-150 ease-out"
style="width: {(progress.checked / progress.total) * 100}%"
></div>
{:else}
Check for updates
{/if}
{:else if displayStatus === 'none' || displayStatus === 'found'}
<Check class="w-3.5 h-3.5 mr-1 text-green-600" />
Check for updates
{:else if displayStatus === 'error'}
<XCircle class="w-3.5 h-3.5 mr-1 text-destructive" />
Check for updates
{:else}
<CircleArrowUp class="w-3.5 h-3.5" />
Check for updates
{/if}
</Button>
+9 -7
View File
@@ -5,7 +5,8 @@
import { appSettings } from '$lib/stores/settings';
interface Props {
open: boolean;
/** Optional — the popover self-manages its open state; bind only if the parent needs it. */
open?: boolean;
action: string;
itemName?: string;
itemType: string;
@@ -17,7 +18,8 @@
unstyled?: boolean;
disabled?: boolean;
onConfirm: () => void;
onOpenChange: (open: boolean) => void;
/** Optional — notified when the popover opens/closes. */
onOpenChange?: (open: boolean) => void;
children: Snippet<[{ open: boolean }]>;
extraContent?: Snippet;
}
@@ -56,7 +58,7 @@
if (open && autoHideMs > 0) {
const timeout = setTimeout(() => {
open = false;
onOpenChange(false);
onOpenChange?.(false);
}, autoHideMs);
return () => clearTimeout(timeout);
}
@@ -65,7 +67,7 @@
function handleConfirm() {
onConfirm();
open = false;
onOpenChange(false);
onOpenChange?.(false);
}
function handleTriggerClick(e: MouseEvent) {
@@ -76,17 +78,17 @@
return;
}
open = !open;
onOpenChange(open);
onOpenChange?.(open);
}
function handleOpenChange(newOpen: boolean) {
open = newOpen;
onOpenChange(newOpen);
onOpenChange?.(newOpen);
}
</script>
<Popover.Root bind:open onOpenChange={handleOpenChange}>
<Popover.Trigger asChild>
<Popover.Trigger>
{#snippet child({ props })}
<button
type="button"
+2 -1
View File
@@ -218,6 +218,7 @@
const isProcessing = $derived(pullStatus === 'pulling' || scanStatus === 'scanning' || isDeleting);
const effectiveEnvId = $derived(envId ?? $currentEnvironment?.id ?? null);
const effectiveEnvName = $derived($currentEnvironment?.id === effectiveEnvId ? $currentEnvironment?.name : null);
const title = $derived(envHasScanning ? 'Pull & scan image' : 'Pull image');
</script>
@@ -241,7 +242,7 @@
{:else}
<Download class="w-5 h-5" />
{/if}
{title}
<span>{title}{#if effectiveEnvName}&nbsp;to <span class="text-amber-500">{effectiveEnvName}</span>{/if}</span>
{#if effectiveImageName}
<code class="text-sm font-normal bg-muted px-1.5 py-0.5 rounded ml-1">{effectiveImageName}</code>
{/if}
+6 -4
View File
@@ -59,7 +59,7 @@
</script>
<Select.Root type="multiple" bind:value bind:open>
<Select.Trigger size="sm" class="{width} text-sm">
<Select.Trigger size="sm" class="{width} max-w-full text-sm overflow-hidden">
{#if hasIcons || defaultIcon}
{@const opt = singleOption()}
{@const IconComponent = opt?.icon || defaultIcon}
@@ -67,11 +67,11 @@
<svelte:component this={IconComponent} class="w-3.5 h-3.5 mr-1.5 {opt?.color || 'text-muted-foreground'} shrink-0" />
{/if}
{/if}
<span class="{value.length === 0 ? 'text-muted-foreground' : ''}">
<span class="truncate {value.length === 0 ? 'text-muted-foreground' : ''}" title={value.length === 1 ? displayLabel() : ''}>
{displayLabel()}
</span>
</Select.Trigger>
<Select.Content>
<Select.Content align="start">
{#if value.length > 0}
<button
type="button"
@@ -85,8 +85,10 @@
<Select.Item value={option.value}>
{#if option.icon}
<svelte:component this={option.icon} class="w-4 h-4 mr-2 {option.color || ''}" />
{:else if option.color}
<span class="w-2 h-2 mr-2 rounded-full shrink-0 {option.color.replace('text-', 'bg-')}"></span>
{/if}
{option.label}
<span class={option.color && !option.icon ? option.color : ''}>{option.label}</span>
</Select.Item>
{/each}
</Select.Content>
@@ -0,0 +1,30 @@
<script lang="ts">
import { SUMMARY_SEVERITIES, severityLabel, getSeverityColor } from '$lib/utils/vulnerability';
interface Props {
/** Counts keyed by severity (critical/high/medium/low). */
counts: { critical: number; high: number; medium: number; low: number };
/** Hide pills whose count is 0 (default: always show all four). */
hideZero?: boolean;
/** Pill size preset. */
size?: 'sm' | 'xs';
class?: string;
}
let { counts, hideZero = false, size = 'sm', class: className = '' }: Props = $props();
const pad = $derived(size === 'xs' ? 'px-2 py-0.5 text-2xs' : 'px-2.5 py-0.5 text-xs');
const pills = $derived(
SUMMARY_SEVERITIES.map((sev) => ({ sev, label: severityLabel(sev), count: counts[sev] }))
.filter((p) => !hideZero || p.count > 0)
);
</script>
<div class="flex flex-wrap items-center gap-2 {className}">
{#each pills as p (p.sev)}
<span class="inline-flex items-center gap-1.5 rounded-full border font-medium {pad} {getSeverityColor(p.sev)}">
<span class="tabular-nums font-semibold">{p.count}</span>
<span>{p.label}</span>
</span>
{/each}
</div>
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import { themeStore, type FontSize } from '$lib/stores/theme';
import { sseConnected } from '$lib/stores/events';
import { Badge } from '$lib/components/ui/badge';
import { Wifi } from 'lucide-svelte';
import type { Component } from 'svelte';
export interface HeaderTab {
id: string;
label: string;
icon: Component;
/** Optional count badge shown next to the label. */
count?: number | string;
/** Optional secondary total, rendered as "count of total" (active tab only). */
total?: number;
/** Show the live-connection (wifi) indicator inside this tab's segment. */
showConnection?: boolean;
}
interface Props {
tabs: HeaderTab[];
activeTab: string;
onTabChange: (id: string) => void;
class?: string;
}
let {
tabs,
activeTab,
onTabChange,
class: className = ''
}: Props = $props();
// Font size scaling (mirrors PageHeader so the active tab matches page titles).
// $themeStore auto-subscribes/cleans up; no manual subscription to leak.
const fontSize = $derived<FontSize>($themeStore.fontSize);
const headerTextClass = $derived.by(() => {
switch (fontSize) {
case 'small': return 'text-lg';
case 'normal': return 'text-xl';
case 'medium': return 'text-2xl';
case 'large': return 'text-2xl';
case 'xlarge': return 'text-3xl';
default: return 'text-xl';
}
});
const headerIconClass = $derived.by(() => {
switch (fontSize) {
case 'small': return 'w-4 h-4';
case 'normal': return 'w-5 h-5';
case 'medium': return 'w-6 h-6';
case 'large': return 'w-6 h-6';
case 'xlarge': return 'w-7 h-7';
default: return 'w-5 h-5';
}
});
function countDisplay(tab: HeaderTab): string | null {
if (tab.count === undefined) return null;
const countStr = typeof tab.count === 'number' ? tab.count.toLocaleString() : tab.count;
if (tab.total !== undefined) {
return `${countStr} of ${tab.total.toLocaleString()}`;
}
return countStr;
}
let active = $derived(tabs.find(t => t.id === activeTab) ?? tabs[0]);
</script>
<!-- A header that IS the switcher: all tabs live in one left-aligned segmented
control, the active segment filled and scaled to page-title size. Replaces
the plain PageHeader on pages that have sub-views. -->
<div class="flex items-center gap-3 {className}">
<div class="inline-flex items-center gap-1 rounded-lg bg-muted/60 p-1">
{#each tabs as tab (tab.id)}
{@const TabIcon = tab.icon}
{@const isActive = tab.id === active?.id}
<button
type="button"
aria-pressed={isActive}
onclick={() => onTabChange(tab.id)}
class="flex items-center gap-2 rounded-md font-bold leading-none transition-colors {headerTextClass} px-3 py-1.5 {isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-background/40'}"
>
<TabIcon class={headerIconClass} />
<span>{tab.label}</span>
{#if countDisplay(tab)}
<Badge variant="secondary" class="text-xs tabular-nums min-w-8 justify-center">
{countDisplay(tab)}
</Badge>
{/if}
{#if tab.showConnection}
<span title={$sseConnected ? 'Live updates active - grid will auto-refresh' : 'Connecting to live updates...'}>
<Wifi class="w-3.5 h-3.5 {$sseConnected ? 'text-emerald-500' : 'text-muted-foreground'}" />
</span>
{/if}
</button>
{/each}
</div>
</div>
+199 -10
View File
@@ -25,12 +25,20 @@
Activity,
Timer,
LibraryBig,
CircleArrowUp
CircleArrowUp,
Pencil,
Check,
GripVertical,
Eye,
EyeOff,
RotateCcw
} from 'lucide-svelte';
import { flip } from 'svelte/animate';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
import { selfUpdate } from '$lib/stores/self-update';
import { appSettings } from '$lib/stores/settings';
import { sidebarPreferencesStore, orderItems } from '$lib/stores/sidebar-preferences';
import * as Avatar from '$lib/components/ui/avatar';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -41,6 +49,7 @@
// One-shot Dockhand update check (#1146). Result is cached for the
// browser session — the Settings → About page reads the same store.
selfUpdate.checkOnce();
sidebarPreferencesStore.init();
});
import type { Permissions } from '$lib/stores/auth';
@@ -67,6 +76,8 @@
async function handleLogout() {
sidebar.setOpenMobile(false);
// Per-user layout must not leak to the next user on this browser
sidebarPreferencesStore.clearLocal();
await authStore.logout();
goto('/login');
}
@@ -119,6 +130,89 @@
{ href: '/audit', Icon: ClipboardList, label: 'Audit log', permission: 'audit_logs', enterpriseOnly: true },
{ href: '/settings', Icon: Settings, label: 'Settings', permission: 'settings' }
] as const;
// --- Sidebar customization (#1252): reorder + hide/show menu items ---
let editMode = $state(false);
let dragHref = $state<string | null>(null);
// Order buffer while dragging - persisted once on drop, not per dragover
let draftOrder = $state<string[] | null>(null);
// Full menu (incl. permission-hidden items) in the user's saved order,
// so reordering never loses positions of items the user can't see.
const orderedItems = $derived(orderItems(menuItems, draftOrder ?? $sidebarPreferencesStore.order));
const hiddenSet = $derived(new Set($sidebarPreferencesStore.hidden));
// Permission filter first, then user-hidden filter
const editableItems = $derived(orderedItems.filter(canSeeMenuItem));
const visibleItems = $derived(editableItems.filter((item) => !hiddenSet.has(item.href)));
// Edit mode only makes sense expanded - exit when the sidebar collapses
$effect(() => {
if (sidebar.state === 'collapsed' && editMode) {
editMode = false;
}
});
function persist(order: string[]) {
sidebarPreferencesStore.save({ order, hidden: [...hiddenSet] });
}
function toggleHidden(href: string) {
const hidden = new Set(hiddenSet);
if (hidden.has(href)) {
hidden.delete(href);
} else {
hidden.add(href);
}
sidebarPreferencesStore.save({ order: orderedItems.map((i) => i.href), hidden: [...hidden] });
}
/**
* Place fromHref before/after toHref in the full order list.
* No-ops when the result wouldn't change, which keeps dragover
* events from thrashing the list while the pointer hovers a row.
*/
function moveTo(fromHref: string, toHref: string, before: boolean) {
const current = orderedItems.map((i) => i.href);
if (!current.includes(fromHref) || !current.includes(toHref)) return;
const order = current.filter((h) => h !== fromHref);
const insertAt = order.indexOf(toHref) + (before ? 0 : 1);
order.splice(insertAt, 0, fromHref);
if (order.join('\n') !== current.join('\n')) {
draftOrder = order;
}
}
function moveBy(href: string, delta: -1 | 1) {
const visible = editableItems.map((i) => i.href);
const from = visible.indexOf(href);
const to = from + delta;
if (to < 0 || to >= visible.length) return;
moveTo(href, visible[to], delta < 0);
if (draftOrder) {
persist(draftOrder);
draftOrder = null;
}
}
function endDrag() {
if (draftOrder) {
persist(draftOrder);
draftOrder = null;
}
dragHref = null;
}
// Transparent 1x1 drag image: suppresses the browser's ghost snapshot so
// the flip animation of the list itself reads as the drag movement.
let ghostImg: HTMLImageElement | null = null;
function transparentGhost(): HTMLImageElement {
if (!ghostImg) {
ghostImg = new Image(1, 1);
ghostImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
}
return ghostImg;
}
</script>
{#snippet versionTooltip()}
@@ -172,17 +266,98 @@
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.Menu>
{#each menuItems as item}
{#if canSeeMenuItem(item)}
<Sidebar.MenuItem>
<Sidebar.MenuButton href={item.href} isActive={isActive(item.href)} tooltipContent={item.label} onclick={() => sidebar.setOpenMobile(false)}>
<item.Icon aria-hidden="true" />
<span class="group-data-[state=collapsed]:hidden">{item.label}</span>
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/if}
{#each editMode ? editableItems : visibleItems as item (item.href)}
<li class="group/menu-item relative" animate:flip={{ duration: 200 }}>
{#if editMode}
<div
role="listitem"
class="flex items-center gap-1.5 px-1.5 py-1 rounded-md text-xs select-none transition-opacity
{hiddenSet.has(item.href) ? 'opacity-40' : ''}
{dragHref === item.href ? 'opacity-30 bg-sidebar-accent' : ''}"
draggable="true"
ondragstart={(e) => {
dragHref = item.href;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
// Safari won't start a drag without data
e.dataTransfer.setData('text/plain', item.href);
e.dataTransfer.setDragImage(transparentGhost(), 0, 0);
}
}}
ondragover={(e) => {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
if (!dragHref || dragHref === item.href) return;
// Only reorder when the pointer is past the row midpoint,
// so a hover near the boundary doesn't flip back and forth
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const before = e.clientY < rect.top + rect.height / 2;
moveTo(dragHref, item.href, before);
}}
ondrop={(e) => e.preventDefault()}
ondragend={endDrag}
>
<button
type="button"
class="cursor-grab text-muted-foreground/60 hover:text-foreground focus:outline-none focus-visible:text-foreground"
aria-label="Reorder {item.label}"
title="Drag to reorder (or use arrow keys)"
onkeydown={(e) => {
if (e.key === 'ArrowUp') { e.preventDefault(); moveBy(item.href, -1); }
if (e.key === 'ArrowDown') { e.preventDefault(); moveBy(item.href, 1); }
}}
>
<GripVertical class="w-3 h-3" />
</button>
<item.Icon class="!w-3.5 !h-3.5 shrink-0" aria-hidden="true" />
<span class="flex-1 truncate">{item.label}</span>
<button
type="button"
class="text-muted-foreground/60 hover:text-foreground transition-colors"
title={hiddenSet.has(item.href) ? `Show ${item.label}` : `Hide ${item.label}`}
aria-label={hiddenSet.has(item.href) ? `Show ${item.label}` : `Hide ${item.label}`}
onclick={() => toggleHidden(item.href)}
>
{#if hiddenSet.has(item.href)}
<EyeOff class="w-3 h-3" />
{:else}
<Eye class="w-3 h-3" />
{/if}
</button>
</div>
{:else}
<Sidebar.MenuButton href={item.href} isActive={isActive(item.href)} tooltipContent={item.label} onclick={() => sidebar.setOpenMobile(false)}>
<item.Icon aria-hidden="true" />
<span class="group-data-[state=collapsed]:hidden">{item.label}</span>
</Sidebar.MenuButton>
{/if}
</li>
{/each}
</Sidebar.Menu>
{#if editMode}
<div class="flex items-center justify-between px-2 py-1 mt-1 text-xs leading-none">
<button
type="button"
class="flex items-center gap-1 whitespace-nowrap font-medium text-muted-foreground hover:text-foreground transition-colors"
title="Reset menu to default order and visibility"
onclick={() => {
sidebarPreferencesStore.reset();
editMode = false;
}}
>
<RotateCcw class="w-3 h-3 shrink-0 text-red-400" />
Reset
</button>
<button
type="button"
class="flex items-center gap-1 whitespace-nowrap font-medium text-muted-foreground hover:text-foreground transition-colors"
onclick={() => (editMode = false)}
>
<Check class="w-3 h-3 shrink-0 text-emerald-500" />
Apply
</button>
</div>
{/if}
</Sidebar.Group>
</Sidebar.Content>
@@ -222,6 +397,20 @@
</a>
{/if}
</span>
<!-- Customize-menu toggle (#1252): invisible until this bottom strip is
hovered, so it never distracts users who don't customize. Hidden
while editing - the Apply button under the menu exits edit mode. -->
{#if !editMode}
<button
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/50 hover:text-foreground hover:bg-sidebar-accent transition-all opacity-0 group-hover/version:opacity-100 focus-visible:opacity-100"
title="Customize menu"
aria-label="Customize menu"
onclick={() => (editMode = true)}
>
<Pencil class="w-3 h-3" />
</button>
{/if}
<div
role="tooltip"
class="pointer-events-none absolute left-0 bottom-full mb-1 whitespace-nowrap rounded-md border bg-popover text-popover-foreground text-xs px-3 py-1.5 shadow-lg opacity-0 group-hover/version:opacity-100 transition-opacity z-50"
+180 -155
View File
@@ -37,6 +37,16 @@
onLoadMore?: () => void;
loadMoreThreshold?: number;
// Sliding-window mode (opt-in, fully isolated). When `windowed` is false
// (default) every windowed branch below is skipped and the grid behaves
// byte-for-byte as before. When true, `data` holds only rows from absolute
// index `windowOffset` of `windowTotal` total; unloaded positions render a
// loading row, and `onWindowShift(target)` is called to fetch a new window.
windowed?: boolean;
windowOffset?: number;
windowTotal?: number;
onWindowShift?: (targetStart: number) => void;
// Visible range callback (for virtual scroll)
onVisibleRangeChange?: (start: number, end: number, total: number) => void;
@@ -85,6 +95,10 @@
hasMore = false,
onLoadMore,
loadMoreThreshold = 200,
windowed = false,
windowOffset = 0,
windowTotal,
onWindowShift,
onVisibleRangeChange,
onRowClick,
highlightedKey,
@@ -373,8 +387,12 @@
// Container width for grow column calculation
let scrollContainerWidth = $state(0);
// Scroll-height row count. In non-windowed mode `rowCount === data.length`, so
// every use below is identical to the original `data.length`.
const rowCount = $derived(windowed ? (windowTotal ?? data.length) : data.length);
// Virtual scroll calculations
const totalHeight = $derived(virtualScroll ? data.length * rowHeight : 0);
const totalHeight = $derived(virtualScroll ? rowCount * rowHeight : 0);
// Memoization state for visibleData to prevent creating new arrays on every scroll
let prevStartIndex = -1;
@@ -382,13 +400,16 @@
let prevDataRef: T[] | null = null;
let cachedVisibleData: T[] = [];
// Memoized startIndex/endIndex/visibleData calculation
// Memoized startIndex/endIndex — ABSOLUTE indices. Non-windowed: rowCount is
// data.length, so this equals the original.
const startIndex = $derived(virtualScroll ? Math.max(0, Math.floor(scrollTop / rowHeight) - bufferRows) : 0);
const endIndex = $derived(
virtualScroll ? Math.min(data.length, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : data.length
virtualScroll ? Math.min(rowCount, Math.ceil((scrollTop + containerHeight) / rowHeight) + bufferRows) : rowCount
);
// Memoized visibleData - only create new array when bounds or data actually change
// Memoized visibleData (ORIGINAL, unchanged) — used by the non-windowed paths.
// In windowed mode the offsets are absolute, so this slice would be wrong; the
// windowed table uses `windowedVisible` instead (below).
const visibleData = $derived.by(() => {
if (!virtualScroll) return data;
@@ -407,15 +428,68 @@
return cachedVisibleData;
});
// Windowed rows for the absolute range [startIndex, endIndex): each position is
// the loaded row or undefined (→ a loading placeholder row).
const windowedVisible = $derived.by<(T | undefined)[]>(() => {
if (!windowed) return [];
const out: (T | undefined)[] = [];
for (let abs = startIndex; abs < endIndex; abs++) {
const local = abs - windowOffset;
out.push(local >= 0 && local < data.length ? data[local] : undefined);
}
return out;
});
// Count of rendered virtual rows (loaded slice, or the windowed range).
const renderedCount = $derived(windowed ? windowedVisible.length : visibleData.length);
const offsetY = $derived(virtualScroll ? startIndex * rowHeight : 0);
// Windowed only: when the viewport nears/leaves the loaded window, ask the
// parent to shift it. Fire once per viewport position while a fetch is pending;
// re-arm when the window actually moves so a failed/superseded shift (window
// never advanced to cover startIndex) can ask again instead of stranding blank
// rows. Keyed on (startIndex, windowOffset): same request suppressed only until
// either the viewport or the loaded window changes.
//
// Failed-shift recovery: if a fetch settles (loading→false) without moving the
// window to cover us, retry ONCE for this exact (startIndex, windowOffset) so a
// transient error doesn't leave permanent blank rows — but not in a loop, so a
// hard-failing endpoint just shows loading placeholders until the user scrolls.
let lastReqStart = -1;
let lastReqOffset = -1;
let retriedUncovered = false;
let wasLoading = false;
$effect(() => {
if (!virtualScroll || !windowed || !onWindowShift) return;
const loadedEnd = windowOffset + data.length;
const needsBelow = endIndex > loadedEnd && loadedEnd < rowCount;
const needsAbove = startIndex < windowOffset && windowOffset > 0;
const settledEdge = wasLoading && !loading; // a fetch just finished
wasLoading = loading;
if (!needsBelow && !needsAbove) { lastReqStart = -1; lastReqOffset = -1; retriedUncovered = false; return; }
const sameRequest = startIndex === lastReqStart && windowOffset === lastReqOffset;
if (sameRequest) {
// Still uncovered for the same (viewport, window). Only re-request on the
// loading→false edge, and only once, so we recover from a failed fetch
// without spinning against a persistently failing endpoint.
if (!(settledEdge && !retriedUncovered)) return;
retriedUncovered = true;
} else {
retriedUncovered = false;
}
lastReqStart = startIndex;
lastReqOffset = windowOffset;
onWindowShift(startIndex);
});
// Notify parent of visible range changes (throttled via RAF)
$effect(() => {
if (virtualScroll && onVisibleRangeChange && data.length > 0) {
// Capture values for RAF callback
const st = scrollTop;
const ch = containerHeight;
const len = data.length;
const len = rowCount; // === data.length when not windowed
const rh = rowHeight;
const cb = onVisibleRangeChange;
@@ -761,91 +835,100 @@
</thead>
{/snippet}
<!--
One data row + its optional expanded row. Shared by the plain virtual body
(tableBody, iterating visibleData) and the windowed body (iterating
windowedVisible). Keeping the row markup in one place means a change reaches
both render paths — the windowed branch used to be a ~85-line copy of this.
-->
{#snippet dataRow(item: T, rowState: ReturnType<typeof getRowState>)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
<!-- Fixed start columns (select checkbox, expand chevron) -->
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleSelection(item[keyField]);
}}
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleExpand(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
<!-- Default: render as text -->
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
<!-- Expanded row content -->
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
</td>
</tr>
{/if}
{/snippet}
{#snippet tableBody()}
<tbody>
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
<!-- Fixed start columns (select checkbox, expand chevron) -->
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleSelection(item[keyField]);
}}
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
toggleExpand(item[keyField]);
}}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
<!-- Configurable columns -->
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
<!-- Default: render as text -->
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
<!-- Fixed end columns (actions) -->
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
<!-- Expanded row content -->
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
</td>
</tr>
{/if}
{@render dataRow(item, getRowState(item, index))}
{/each}
</tbody>
{/snippet}
@@ -876,79 +959,21 @@
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {offsetY}px; padding: 0; border: none;"></td></tr>
{/if}
<!-- Visible rows -->
{#each visibleData as item, index (item[keyField])}
{@const rowState = getRowState(item, index)}
<tr
class="group cursor-pointer {rowState.isHighlighted ? 'selected' : ''} {rowState.isSelected ? 'checkbox-selected' : ''} {rowState.isExpanded ? 'row-expanded' : ''} {rowClass?.(item) ?? ''}"
onclick={(e) => onRowClick?.(item, e)}
>
{#each fixedStartCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-1 {colId === 'select' ? 'select-col' : ''} {colId === 'expand' ? 'expand-col' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if colId === 'select' && selectable}
{#if rowState.isSelectable}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleSelection(item[keyField]); }}
class="flex items-center justify-center w-full h-full min-h-[24px] transition-colors cursor-pointer {rowState.isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-40 hover:!opacity-100'}"
>
{#if rowState.isSelected}
<CheckSquare class="w-3.5 h-3.5 text-muted-foreground" />
{:else}
<SquareIcon class="w-3.5 h-3.5 text-muted-foreground" />
{/if}
</button>
{/if}
{:else if colId === 'expand' && expandable}
<button
type="button"
onclick={(e) => { e.stopPropagation(); toggleExpand(item[keyField]); }}
class="flex items-center justify-center transition-colors cursor-pointer opacity-50 hover:opacity-100"
title={rowState.isExpanded ? 'Collapse' : 'Expand'}
>
{#if rowState.isExpanded}
<ChevronDown class="w-4 h-4 text-muted-foreground" />
{:else}
<ChevronRight class="w-4 h-4 text-muted-foreground" />
{/if}
</button>
{:else if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
{#each orderedColumns as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
{#if colConfig}
<td class="py-1.5 px-2 {colConfig.noTruncate ? 'no-truncate' : ''}" style="width: {getDisplayWidth(colId)}px">
{#if cell}
{@render cell(colConfig, item, rowState)}
{:else}
{String(item[colId as keyof T] ?? '')}
{/if}
</td>
{/if}
{/each}
{#each fixedEndCols as colId (colId)}
{@const colConfig = columnConfigMap.get(colId)}
<td class="py-1.5 px-2 text-right actions-col" style="width: {getDisplayWidth(colId)}px" onclick={(e) => e.stopPropagation()}>
{#if cell}
{@render cell(colConfig!, item, rowState)}
{/if}
</td>
{/each}
</tr>
{#if rowState.isExpanded && expandedRow}
<tr class="expanded-row">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length}>
{@render expandedRow(item, rowState)}
{#each (windowed ? windowedVisible : visibleData) as item, index (windowed ? startIndex + index : item![keyField])}
{#if item === undefined}
<!-- Windowed: this absolute row isn't loaded yet — show a loading row. -->
<tr class="animate-pulse">
<td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {rowHeight}px" class="px-2">
<div class="h-3 bg-muted rounded" style="width: 30%"></div>
</td>
</tr>
{:else}
{@render dataRow(item, getRowState(item, index))}
{/if}
{/each}
<!-- Bottom spacer -->
{#if totalHeight - offsetY - (visibleData.length * rowHeight) > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (visibleData.length * rowHeight)}px; padding: 0; border: none;"></td></tr>
{#if totalHeight - offsetY - (renderedCount * rowHeight) > 0}
<tr><td colspan={fixedStartCols.length + orderedColumns.length + fixedEndCols.length} style="height: {totalHeight - offsetY - (renderedCount * rowHeight)}px; padding: 0; border: none;"></td></tr>
{/if}
<!-- Footer (rendered at the bottom of virtual scroll) -->
{#if footer}