Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@
"@milkdown/theme-nord": "^7.18.0",
"@openuidev/svelte-lang": "^0.4.2",
"@tabler/icons-svelte-runes": "^3.40.0",
"@tanstack/virtual-core": "3.19.9",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.2.0",
"@tauri-apps/plugin-fs": "^2.2.0",
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import Button from './lib/components/Button.svelte';
import LazyRootDialog from './lib/components/LazyRootDialog.svelte';
import LazyRootView from './lib/components/LazyRootView.svelte';
import { activateVirtualAudience } from './lib/virtualization/scrollRestoration';

// Root surfaces are intentionally loaded only after startup has resolved the
// route/auth boundary. Keep these as direct import() calls so Vite can split
Expand Down Expand Up @@ -212,6 +213,10 @@
});

// Handle authentication state changes
$effect(() => {
activateVirtualAudience($authStore.isAuthenticated ? `user:${$authStore.user?.id}` : 'public');
});

$effect(() => {
if ($authStore.isAuthenticated && setupCompleted) {
showLoginDialog = false;
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/design-system/viewer/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@

// Data Display Page
import ItemKeyPage from './pages/ItemKey.svelte'
import VirtualRowsPage from './pages/VirtualRows.svelte'

// Picker Component Pages
import ItemPickerPage from './pages/ItemPickerPage.svelte'
Expand Down Expand Up @@ -201,6 +202,7 @@
title: 'Data Display',
items: [
{ id: 'itemkey', label: 'ItemKey', icon: Hash },
{ id: 'virtualrows', label: 'VirtualRows', icon: List },
]
},
{
Expand Down Expand Up @@ -284,7 +286,9 @@

<!-- Main Content -->
<main class="flex-1 overflow-y-auto">
{#if currentPage === 'board-example'}
{#if currentPage === 'virtualrows'}
<VirtualRowsPage />
{:else if currentPage === 'board-example'}
<BoardExample />
{:else if currentPage === 'form-example'}
<FormExample />
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/design-system/viewer/pages/VirtualRows.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<script>
import VirtualRows from '../../../lib/components/VirtualRows.svelte';
import Checkbox from '../../../lib/components/Checkbox.svelte';

let enabled = $state(true);
let root = $state(null);
let body = $state(null);
let selection = $state(new Set());
const records = Array.from({ length: 10000 }, (_, index) => ({
key: `record:${index}`, label: `Independent record ${index + 1}`,
status: index % 3 === 0 ? 'Ready' : 'Draft',
}));

function select(key, checked) {
const next = new Set(selection);
if (checked) next.add(key);
else next.delete(key);
selection = next;
}
</script>

<section class="p-6 space-y-4">
<h1 class="text-2xl font-semibold">VirtualRows</h1>
<p>One shared row renderer, independent entity keys and a native table. No collection stores or fetching.</p>
<Checkbox bind:checked={enabled} label="Virtualize loaded rows" />
<p>{selection.size} selected across 10,000 loaded records. Selection belongs to this consumer.</p>
<div bind:this={root} class="overflow-auto border rounded" style="height: 480px; overflow-anchor: none;">
<table class="w-full">
<thead><tr><th>Selected</th><th>Record</th><th>Status</th></tr></thead>
<tbody bind:this={body}>
<VirtualRows items={records} getKey={(record) => record.key} {enabled}
scrollElement={root} listElement={body} identity="catalog:independent-records" estimateSize={() => 48}>
{#snippet row(record, virtual)}
<tr use:virtual.attach class="border-t" style="height: 48px;">
<td class="px-3"><Checkbox checked={selection.has(record.key)} onchange={(checked) => select(record.key, checked)} label={`Select ${record.label}`} /></td>
<td class="px-3">{record.label}</td><td class="px-3">{record.status}</td>
</tr>
{/snippet}
{#snippet spacer(size)}
<tr aria-hidden="true"><td colspan="3" style={`height: ${size}px; padding: 0; border: 0;`}></td></tr>
{/snippet}
</VirtualRows>
</tbody>
</table>
</div>
</section>
72 changes: 72 additions & 0 deletions frontend/src/lib/components/VirtualRows.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<script lang="ts">
import { getContext, untrack } from 'svelte';
import type { Snippet } from 'svelte';
import { useVirtualRows } from '../virtualization/useVirtualRows.svelte';
import type { ScrollRoot } from '../virtualization/types';
import { VIRTUAL_SCROLL_CONTEXT } from '../virtualization/types';
import VirtualRow from '../virtualization/VirtualRow.svelte';

let {
items = [], getKey, estimateSize = () => 48, identity = '', enabled = true,
layoutRevision = '', fixedSize, scrollElement, listElement = null, row, spacer = null,
onrangechange = null, dragSources = false, dragRevision = '', sharedModel = null, pane = 'main',
} = $props<{
items: any[];
getKey: (item: any, index: number) => string;
estimateSize?: (item: any, index: number) => number;
identity?: string;
enabled?: boolean;
layoutRevision?: string;
fixedSize?: number;
scrollElement?: ScrollRoot | null;
listElement?: HTMLElement | null;
row: Snippet<[any, { index: number; attach: (node: HTMLElement) => { destroy(): void } }]>;
spacer?: Snippet<[number]> | null;
onrangechange?: ((keys: string[]) => void) | null;
dragSources?: boolean;
dragRevision?: string;
sharedModel?: ReturnType<typeof useVirtualRows<any>> | null;
pane?: string;
}>();

const shellRoot = getContext<{ element: HTMLElement | null; revision?: string } | undefined>(VIRTUAL_SCROLL_CONTEXT);
const rows = $derived(items.map((value, index) => ({ key: getKey(value, index), value, estimatedSize: estimateSize(value, index) })));
// A paired surface supplies its model for this renderer's entire lifetime.
const virtual = untrack(() => sharedModel) ?? useVirtualRows({
getRows: () => rows,
getIdentity: () => identity,
getEnabled: () => enabled,
getScrollElement: () => scrollElement === undefined ? shellRoot?.element ?? null : scrollElement,
getListElement: () => listElement,
getLayoutRevision: () => `${layoutRevision}|${shellRoot?.revision ?? ''}`,
getFixedSize: () => fixedSize,
});

$effect(() => { onrangechange?.(virtual.visibleKeys); });

let dragSession: { invalidate(): void; destroy(): void } | null = null;
$effect(() => {
if (!dragSources) return;
const root = scrollElement === undefined ? shellRoot?.element : scrollElement;
if (!root || !('ownerDocument' in root)) return;
let disposed = false;
void import('../virtualization/virtualDragSession').then(({ createVirtualDragSession }) => {
if (!disposed) dragSession = createVirtualDragSession(virtual.controller, root);
});
return () => { disposed = true; dragSession?.destroy(); dragSession = null; };
});
$effect(() => { void identity; void dragRevision; dragSession?.invalidate(); });

</script>

{#each virtual.tokens as token (token.key)}
{#if token.kind === 'gap'}
{#if spacer}
{@render spacer(token.size)}
{:else}
<div aria-hidden="true" data-virtual-gap style="height: {token.size}px; overflow-anchor: none; pointer-events: none;"></div>
{/if}
{:else}
<VirtualRow descriptor={virtual.controller.rows[token.row.index]} position={token.row} controller={virtual.controller} {row} {pane} />
{/if}
{/each}
2 changes: 2 additions & 0 deletions frontend/src/lib/editors/EditableField.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script>
import InlineEditorActions from './InlineEditorActions.svelte';
import { useRowRetention } from '../virtualization/useRowRetention.svelte';

// Single inline-edit primitive. It owns the edit state machine and the
// commit/cancel outcome rule; callers own parsing, validation and the
Expand Down Expand Up @@ -37,6 +38,7 @@
let suppressCommit = false;
let container = $state(null);
let clickTimeout = null;
useRowRetention(() => editing, 'edit');

export function isEditing() {
return editing;
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/lib/features/collections/BacklogItemRow.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<script>
import GripVertical from '@lucide/svelte/icons/grip-vertical';
import LazyRender from '../../components/LazyRender.svelte';
import DropIndicator from '../../layout/DropIndicator.svelte';
import WorkItemRow from '../items/WorkItemRow.svelte';
import BacklogItemActions from './BacklogItemActions.svelte';

let {
item, sectionId, workspace, itemTypes, statuses, statusCategories, styles, dragState,
backlogRowGap = 2, assignableIterations = [], pendingActionItemIds = new Set(),
visibleFields = [], onOpenItem, onMoveItemToBoundary, onAssignItemToIteration,
storyPointsConfiguredForItem = null, storyPointsPendingItemIds = new Set(),
onUpdateStoryPoints, itemDndAction = () => {}, deferred = true, virtualRow = null,
} = $props();
const visibleFieldSet = $derived(new Set(visibleFields));
function virtualAttachment(node) { return virtualRow?.attach(node); }
</script>

{#snippet content()}
{#if dragState.get(item.id)?.closestEdge}
<DropIndicator edge={dragState.get(item.id)?.closestEdge} gap={backlogRowGap} />
{/if}
<WorkItemRow {item} {workspace} {itemTypes} {statuses} {statusCategories}
dataTestid={`backlog-row-${item.id}`}
onclick={(event) => onOpenItem?.(item.id, event)}
showStatus={visibleFieldSet.has('status')} showPriority={visibleFieldSet.has('priority')}
showParent={visibleFieldSet.has('parent')} showAssignee={visibleFieldSet.has('assignee')}
showDueDate={visibleFieldSet.has('due_date')} showMilestones={visibleFieldSet.has('milestone')}
showStoryPoints={visibleFieldSet.has('story_points') && (storyPointsConfiguredForItem?.(item) ?? false)}
storyPointsSaving={storyPointsPendingItemIds.has(item.id)}
onStoryPointsChange={(value) => onUpdateStoryPoints?.(item, value)}
>
{#snippet leading()}
<div data-testid={`backlog-drag-${item.id}`} class="cursor-grab active:cursor-grabbing" style={styles.dragHandleStyle}>
<GripVertical class="w-4 h-4" />
</div>
{/snippet}
{#snippet trailing()}
<BacklogItemActions {item} iterations={assignableIterations}
disabled={pendingActionItemIds.has(item.id)}
onMoveToBoundary={onMoveItemToBoundary} onAssignIteration={onAssignItemToIteration} />
{/snippet}
</WorkItemRow>
{/snippet}

<div use:virtualAttachment use:itemDndAction={item.id} class="relative"
style={`padding-bottom: ${backlogRowGap}px;`}
data-testid="backlog-item" data-item-card data-item-id={item.id} data-section-id={sectionId}>
{#if deferred}
<LazyRender height={36}>{@render content()}</LazyRender>
{:else}
{@render content()}
{/if}
</div>
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
<script>
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import GripVertical from '@lucide/svelte/icons/grip-vertical';
import Play from '@lucide/svelte/icons/play';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import X from '@lucide/svelte/icons/x';
import { t } from '../../stores/i18n.svelte.js';
import { formatDateShort } from '../../utils/dateFormatter.js';
import Lozenge from '../../components/Lozenge.svelte';
import WorkItemRow from '../items/WorkItemRow.svelte';
import DropIndicator from '../../layout/DropIndicator.svelte';
import LazyRender from '../../components/LazyRender.svelte';
import BacklogItemActions from './BacklogItemActions.svelte';
import BacklogItemRow from './BacklogItemRow.svelte';

let {
iteration = null,
Expand All @@ -30,7 +26,7 @@
pendingActionItemIds = new Set(),
visibleFields = [],
onToggleCollapse,
onOpenItem,
onOpenItem = null,
onMoveItemToBoundary = null,
onAssignItemToIteration = null,
onStartIteration = null,
Expand All @@ -41,6 +37,8 @@
onUpdateStoryPoints = null,
itemDndAction = () => {},
sectionDndAction = () => {},
headerOnly = false,
virtualRow = null,
} = $props();

const statusColors = {
Expand All @@ -62,7 +60,7 @@
let canStart = $derived(iteration && !iteration.is_global && iteration.status === 'planned');
let canComplete = $derived(iteration && !iteration.is_global && iteration.status === 'active');
let sectionId = $derived(iteration ? iteration.id : 'unassigned');
let visibleFieldSet = $derived(new Set(visibleFields));
function virtualAttachment(node) { return virtualRow?.attach(node); }

let headerClass = $derived(
`w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors select-none iteration-header` +
Expand All @@ -76,7 +74,8 @@
</script>

<div
class="mb-4"
use:virtualAttachment
class={headerOnly ? 'pt-4 pb-1' : 'mb-4'}
data-testid={`backlog-iteration-section-${sectionId}`}
data-iteration-section
data-iteration-id={sectionId}
Expand Down Expand Up @@ -165,7 +164,7 @@
</div>

<!-- Section Body -->
{#if !collapsed}
{#if !headerOnly && !collapsed}
<div class="mt-1">
{#if items.length === 0}
<div
Expand All @@ -182,55 +181,11 @@
{:else}
<div class="flex flex-col" style={`row-gap: ${backlogRowGap}px;`}>
{#each items as item (item.id)}
<div
use:itemDndAction={item.id}
class="relative"
data-testid="backlog-item"
data-item-card
data-item-id={item.id}
data-section-id={sectionId}
>
<LazyRender height={36}>
{#snippet children()}
{#if dragState.get(item.id)?.closestEdge}
<DropIndicator edge={dragState.get(item.id)?.closestEdge} gap={backlogRowGap} />
{/if}

<WorkItemRow
{item}
{workspace}
{itemTypes}
{statuses}
{statusCategories}
onclick={(e) => onOpenItem?.(item.id, e)}
showStatus={visibleFieldSet.has('status')}
showPriority={visibleFieldSet.has('priority')}
showParent={visibleFieldSet.has('parent')}
showAssignee={visibleFieldSet.has('assignee')}
showDueDate={visibleFieldSet.has('due_date')}
showMilestones={visibleFieldSet.has('milestone')}
showStoryPoints={visibleFieldSet.has('story_points') && (storyPointsConfiguredForItem?.(item) ?? false)}
storyPointsSaving={storyPointsPendingItemIds.has(item.id)}
onStoryPointsChange={(value) => onUpdateStoryPoints?.(item, value)}
>
{#snippet leading()}
<div class="cursor-grab active:cursor-grabbing" style="{styles.dragHandleStyle}">
<GripVertical class="w-4 h-4" />
</div>
{/snippet}
{#snippet trailing()}
<BacklogItemActions
{item}
iterations={assignableIterations}
disabled={pendingActionItemIds.has(item.id)}
onMoveToBoundary={onMoveItemToBoundary}
onAssignIteration={onAssignItemToIteration}
/>
{/snippet}
</WorkItemRow>
{/snippet}
</LazyRender>
</div>
<BacklogItemRow {item} {sectionId} {workspace} {itemTypes} {statuses}
{statusCategories} {styles} {dragState} {backlogRowGap} {assignableIterations}
{pendingActionItemIds} {visibleFields} {onOpenItem} {onMoveItemToBoundary}
{onAssignItemToIteration} {storyPointsConfiguredForItem} {storyPointsPendingItemIds}
{onUpdateStoryPoints} {itemDndAction} />
{/each}
</div>
{/if}
Expand Down
Loading
Loading