From 208f0e8a1392dcb416b9eacb0f15098300459a51 Mon Sep 17 00:00:00 2001 From: unsoloyo Date: Sun, 14 Jun 2026 10:04:53 +0200 Subject: [PATCH 1/2] Refactor stream navigation buttons and enhance stream display with hierarchical grouping --- ...s.$streamName.$from.$direction.$amount.jsx | 8 +- app/routes/streams.$streamName.jsx | 20 +- app/routes/streams._index.jsx | 176 ++++++++++++++---- public/assets/css/material-overrides.css | 62 ++++++ 4 files changed, 211 insertions(+), 55 deletions(-) diff --git a/app/routes/streams.$streamName.$from.$direction.$amount.jsx b/app/routes/streams.$streamName.$from.$direction.$amount.jsx index 3fd0cdf..25712fc 100644 --- a/app/routes/streams.$streamName.$from.$direction.$amount.jsx +++ b/app/routes/streams.$streamName.$from.$direction.$amount.jsx @@ -174,26 +174,26 @@ export default function EventStreamPaged() {
{prev <= 0 ? ( ) : ( - Prev + Newer )} {next <= 0 ? ( ) : ( - Next + Older )}
diff --git a/app/routes/streams.$streamName.jsx b/app/routes/streams.$streamName.jsx index d03a757..2232acf 100644 --- a/app/routes/streams.$streamName.jsx +++ b/app/routes/streams.$streamName.jsx @@ -13,9 +13,8 @@ export async function loader({ params, request }) { const storeNameOverride = url.searchParams.get('store') || undefined; const { eventstore } = await getEventStore({ readOnly: true }, storeNameOverride); - const from = 1; const amount = 10; - const direction = 'forwards'; + const direction = 'backwards'; const events = []; const streamIndex = eventstore.streams[streamName]?.index; const streamIndexMetadata = streamIndex?.metadata || null; @@ -31,11 +30,12 @@ export async function loader({ params, request }) { partitionMetadata = writePartition.metadata || null; } - const until = from + amount - 1; const streamLength = eventstore.getStreamVersion(streamName); + const from = streamLength; // start from the newest event + const until = Math.max(1, from - amount + 1); let stream = eventstore.getEventStream(streamName); if (stream !== false) { - stream = stream.from(from).forwards(amount); + stream = stream.from(from).backwards(amount); stream.forEach((payload, metadata, stream) => { events.push({ payload, metadata, stream }); }); @@ -46,8 +46,8 @@ export async function loader({ params, request }) { stream: events, direction, amount, - next: until >= streamLength ? 0 : until + 1, - prev: from - amount, + next: until <= 1 ? 0 : until - 1, // older events + prev: 0, // already at newest streamInfo: { indexMetadata: streamIndexMetadata, matcher, @@ -163,26 +163,26 @@ export default function EventStream() {
{prev <= 0 ? ( ) : ( - Prev + Newer )} {next <= 0 ? ( ) : ( - Next + Older )}
diff --git a/app/routes/streams._index.jsx b/app/routes/streams._index.jsx index 45ddce3..1857068 100644 --- a/app/routes/streams._index.jsx +++ b/app/routes/streams._index.jsx @@ -1,5 +1,6 @@ import fs from 'node:fs'; import { Link, useLoaderData } from 'react-router'; +import { useState } from 'react'; import getEventStore from '../../eventstore'; import DateFormat from '../components/date'; import Json from '../components/json'; @@ -23,14 +24,89 @@ export async function loader({ request }) { }; }); + // Newest streams first + streams.sort((a, b) => b.crtime - a.crtime); + return { storeName: eventstore.storeName, streams }; } +function detectSeparator(streams) { + for (const sep of ['-', '.', '/']) { + const prefixes = new Set(streams.map((s) => s.name.split(sep)[0])); + if (prefixes.size < streams.length) return sep; + } + return null; +} + +function buildTree(streams) { + const sep = detectSeparator(streams); + if (!sep) return null; + const categories = {}; + for (const stream of streams) { + const idx = stream.name.indexOf(sep); + const category = idx === -1 ? stream.name : stream.name.slice(0, idx); + if (!categories[category]) categories[category] = []; + categories[category].push(stream); + } + // Only use tree view when there's actual grouping + if (Object.keys(categories).length === streams.length) return null; + return { sep, categories }; +} + +function CategoryGroup({ category, streams, sep }) { + const [open, setOpen] = useState(false); + return ( + <> + setOpen((o) => !o)}> + +
+ + {open ? 'folder_open' : 'folder'} + {category} + + + {streams.length} stream{streams.length !== 1 ? 's' : ''} + +
+ + + {open && + streams.map((stream) => { + const rest = stream.name.startsWith(category + sep) + ? stream.name.slice(category.length + sep.length) + : stream.name; + return ( + + + + receipt_long + {rest} + + + + + + + + + {stream.length} events + + + + + + ); + })} + + ); +} + export default function StreamsIndex() { const { storeName, streams } = useLoaderData(); + const tree = buildTree(streams); const [start, end, nextPage, prevPage, hasNext, hasPrev] = usePagination(streams.length); return ( @@ -48,10 +124,17 @@ export default function StreamsIndex() { table_rows {streams.length} total streams - - layers - {start + 1}-{Math.min(end, streams.length)} visible - + {tree ? ( + + account_tree + {Object.keys(tree.categories).length} categories + + ) : ( + + layers + {start + 1}-{Math.min(end, streams.length)} visible + + )} @@ -75,45 +158,56 @@ export default function StreamsIndex() { - {streams.slice(start, end).map((stream) => ( - - - {stream.name} - - - - - - - - {stream.length} events - - - - - - ))} + {tree + ? Object.entries(tree.categories).map(([category, catStreams]) => ( + + )) + : streams.slice(start, end).map((stream) => ( + + + {stream.name} + + + + + + + + {stream.length} events + + + + + + ))} - - - -
- - Showing {start + 1}-{Math.min(end, streams.length)} of {streams.length}{' '} - streams - -
- - + {!tree && ( + + + +
+ + Showing {start + 1}-{Math.min(end, streams.length)} of{' '} + {streams.length} streams + +
+ + +
-
- - - + + + + )}
diff --git a/public/assets/css/material-overrides.css b/public/assets/css/material-overrides.css index 63488be..5d296cd 100644 --- a/public/assets/css/material-overrides.css +++ b/public/assets/css/material-overrides.css @@ -846,4 +846,66 @@ .copyright > i { vertical-align: middle; +} + +/* ── Stream tree (hierarchical stream browser) ── */ + +.stream-cat-row { + cursor: pointer; + user-select: none; + background: color-mix(in srgb, var(--c-primary) 6%, var(--c-bkg-card)) !important; + transition: background 0.15s ease; +} + +.stream-cat-row:hover { + background: color-mix(in srgb, var(--c-primary) 12%, var(--c-bkg-card)) !important; +} + +.stream-cat-cell { + padding: 10px 16px !important; +} + +.stream-cat-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.stream-cat-toggle { + display: flex; + align-items: center; + gap: 8px; + color: var(--c-primary); +} + +.stream-cat-toggle .material-icons { + font-size: 20px; +} + +.stream-cat-label { + font-weight: 600; + font-size: 0.97rem; + color: var(--c-text-base); +} + +.stream-leaf-row td { + padding-left: 44px !important; + border-top: none !important; +} + +.stream-leaf-row:last-of-type td { + border-bottom: 1px solid var(--c-border); +} + +.stream-leaf-name a { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.stream-leaf-icon { + font-size: 15px !important; + color: var(--c-text-muted); + flex-shrink: 0; } \ No newline at end of file From ca071acacf21f6290c2bce531a6c5124fe14e451 Mon Sep 17 00:00:00 2001 From: Alexander Berl Date: Sun, 14 Jun 2026 17:36:00 +0200 Subject: [PATCH 2/2] Improve folder semantic for category streams --- app/hooks/paginate.js | 11 +- app/routes/streams._index.jsx | 327 +++++++++++++++-------- public/assets/css/material-overrides.css | 15 +- 3 files changed, 224 insertions(+), 129 deletions(-) diff --git a/app/hooks/paginate.js b/app/hooks/paginate.js index e8b0539..cf8752c 100644 --- a/app/hooks/paginate.js +++ b/app/hooks/paginate.js @@ -1,7 +1,16 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; export default function usePagination(length) { const [page, setPage] = useState({ start: 0, size: 10 }); + + // Keep the current page within bounds when the number of items shrinks. + useEffect(() => { + const maxStart = length <= 0 ? 0 : Math.floor((length - 1) / page.size) * page.size; + if (page.start > maxStart) { + setPage((current) => ({ ...current, start: maxStart })); + } + }, [length, page.start, page.size]); + const nextPage = useCallback( () => setPage((current) => ({ ...current, start: current.start + current.size })), [] diff --git a/app/routes/streams._index.jsx b/app/routes/streams._index.jsx index 1857068..4e9ad48 100644 --- a/app/routes/streams._index.jsx +++ b/app/routes/streams._index.jsx @@ -15,7 +15,7 @@ export async function loader({ request }) { const streams = Object.keys(eventstore.streams).map((streamName) => { const stream = eventstore.streams[streamName].index; - const crtime = fs.statSync(stream.fileName).birthtimeMs; + const crtime = stream.crtime || fs.statSync(stream.fileName).birthtimeMs; return { name: streamName, length: stream.length, @@ -24,8 +24,13 @@ export async function loader({ request }) { }; }); - // Newest streams first - streams.sort((a, b) => b.crtime - a.crtime); + // Streams starting with '_' should always be shown first, then newest streams. + streams.sort((a, b) => { + const aIsSystem = a.name.startsWith('_'); + const bIsSystem = b.name.startsWith('_'); + if (aIsSystem !== bIsSystem) return aIsSystem ? -1 : 1; + return b.crtime - a.crtime; + }); return { storeName: eventstore.storeName, @@ -33,81 +38,201 @@ export async function loader({ request }) { }; } -function detectSeparator(streams) { - for (const sep of ['-', '.', '/']) { - const prefixes = new Set(streams.map((s) => s.name.split(sep)[0])); - if (prefixes.size < streams.length) return sep; +/** Separates flat streams (no '/') from hierarchical ones and builds a recursive tree for the latter. */ +function buildStreamTree(streams) { + const createNode = () => ({ children: {}, stream: null }); + const root = createNode(); + const topLevelEntries = []; + + for (const stream of streams) { + if (!stream.name.includes('/')) { + topLevelEntries.push({ type: 'flat', stream }); + continue; + } + const parts = stream.name.split('/'); + const topLevelSegment = parts[0]; + + if (!root.children[topLevelSegment]) { + root.children[topLevelSegment] = createNode(); + topLevelEntries.push({ + type: 'group', + segment: topLevelSegment, + path: topLevelSegment, + node: root.children[topLevelSegment] + }); + } + + let node = root.children[topLevelSegment]; + for (const part of parts.slice(1, -1)) { + if (!node.children[part]) node.children[part] = createNode(); + node = node.children[part]; + } + const last = parts.at(-1); + if (!node.children[last]) node.children[last] = createNode(); + node.children[last].stream = stream; } - return null; + + return { topLevelEntries, treeRoot: root }; } -function buildTree(streams) { - const sep = detectSeparator(streams); - if (!sep) return null; - const categories = {}; - for (const stream of streams) { - const idx = stream.name.indexOf(sep); - const category = idx === -1 ? stream.name : stream.name.slice(0, idx); - if (!categories[category]) categories[category] = []; - categories[category].push(stream); +function countLeaves(node) { + let count = node.stream ? 1 : 0; + for (const child of Object.values(node.children)) count += countLeaves(child); + return count; +} + +function buildVisibleRows(topLevelEntries, expandedPaths) { + const rows = []; + + const addGroupRow = (segment, node, depth, path) => { + const isOpen = expandedPaths.has(path); + rows.push({ + kind: 'group', + id: `group:${path}`, + path, + segment, + depth, + node, + isOpen, + leafCount: countLeaves(node), + counted: !isOpen + }); + + if (!isOpen) return; + + for (const [childSegment, childNode] of Object.entries(node.children)) { + const childPath = `${path}/${childSegment}`; + const hasChildren = Object.keys(childNode.children).length > 0; + if (hasChildren) { + addGroupRow(childSegment, childNode, depth + 1, childPath); + } else if (childNode.stream) { + rows.push({ + kind: 'leaf', + id: `leaf:${childNode.stream.name}`, + path: childPath, + segment: childSegment, + depth: depth + 1, + stream: childNode.stream, + counted: true + }); + } + } + }; + + for (const entry of topLevelEntries) { + if (entry.type === 'flat') { + rows.push({ + kind: 'leaf', + id: `leaf:${entry.stream.name}`, + path: entry.stream.name, + segment: entry.stream.name, + depth: 0, + stream: entry.stream, + counted: true + }); + continue; + } + + addGroupRow(entry.segment, entry.node, 0, entry.path); } - // Only use tree view when there's actual grouping - if (Object.keys(categories).length === streams.length) return null; - return { sep, categories }; + + return rows; } -function CategoryGroup({ category, streams, sep }) { - const [open, setOpen] = useState(false); - return ( - <> - setOpen((o) => !o)}> - +function buildPagedRows(rows, start, end) { + const selectedIds = new Set(); + const ancestorPaths = new Set(); + let countedIndex = 0; + + const collectAncestors = (path) => { + if (!path.includes('/')) return; + const parts = path.split('/'); + for (let i = 1; i < parts.length; i++) { + ancestorPaths.add(parts.slice(0, i).join('/')); + } + }; + + for (const row of rows) { + if (!row.counted) continue; + if (countedIndex >= start && countedIndex < end) { + selectedIds.add(row.id); + collectAncestors(row.path); + } + countedIndex += 1; + } + + return rows.filter((row) => selectedIds.has(row.id) || (row.kind === 'group' && ancestorPaths.has(row.path))); +} + +function renderRow(row, expandedPaths, onToggle) { + const indent = `calc(0.75rem + ${row.depth * 1.5}em)`; + + if (row.kind === 'group') { + return ( + onToggle(row.path)}> +
- {open ? 'folder_open' : 'folder'} - {category} + {expandedPaths.has(row.path) ? 'folder_open' : 'folder'} + {row.segment} - {streams.length} stream{streams.length !== 1 ? 's' : ''} + {row.leafCount} stream{row.leafCount !== 1 ? 's' : ''}
- {open && - streams.map((stream) => { - const rest = stream.name.startsWith(category + sep) - ? stream.name.slice(category.length + sep.length) - : stream.name; - return ( - - - - receipt_long - {rest} - - - - - - - - - {stream.length} events - - - - - - ); - })} - + ); + } + + return ( + + + + receipt_long + {row.segment} + + + + + + + + + {row.stream.length} events + + + + + ); } export default function StreamsIndex() { const { storeName, streams } = useLoaderData(); - const tree = buildTree(streams); - const [start, end, nextPage, prevPage, hasNext, hasPrev] = usePagination(streams.length); + const { topLevelEntries, treeRoot } = buildStreamTree(streams); + const topLevelGroupCount = Object.keys(treeRoot.children).length; + const [expandedPaths, setExpandedPaths] = useState(() => new Set()); + + const togglePath = (path) => { + setExpandedPaths((current) => { + const next = new Set(current); + if (next.has(path)) { + next.delete(path); + } else { + next.add(path); + } + return next; + }); + }; + + const rows = buildVisibleRows(topLevelEntries, expandedPaths); + const countedEntriesTotal = rows.reduce((count, row) => count + (row.counted ? 1 : 0), 0); + const [start, end, nextPage, prevPage, hasNext, hasPrev] = usePagination(countedEntriesTotal); + const visibleEntries = buildPagedRows(rows, start, end); + + const visibleStart = countedEntriesTotal === 0 ? 0 : Math.min(start + 1, countedEntriesTotal); + const visibleEnd = Math.min(end, countedEntriesTotal); return (
@@ -124,17 +249,16 @@ export default function StreamsIndex() { table_rows {streams.length} total streams - {tree ? ( + {topLevelGroupCount > 0 && ( account_tree - {Object.keys(tree.categories).length} categories - - ) : ( - - layers - {start + 1}-{Math.min(end, streams.length)} visible + {topLevelGroupCount} top-level groups )} + + layers + {visibleStart}-{visibleEnd} paged items +
@@ -157,57 +281,28 @@ export default function StreamsIndex() { Metadata - - {tree - ? Object.entries(tree.categories).map(([category, catStreams]) => ( - - )) - : streams.slice(start, end).map((stream) => ( - - - {stream.name} - - - - - - - - {stream.length} events - - - - - - ))} - - {!tree && ( - - - -
- - Showing {start + 1}-{Math.min(end, streams.length)} of{' '} - {streams.length} streams - -
- - -
+ + {visibleEntries.map((row) => renderRow(row, expandedPaths, togglePath))} + + + + +
+ + Showing {visibleStart}-{visibleEnd} of {streams.length} streams + +
+ +
- - - - )} +
+ + +
diff --git a/public/assets/css/material-overrides.css b/public/assets/css/material-overrides.css index 5d296cd..13b9daa 100644 --- a/public/assets/css/material-overrides.css +++ b/public/assets/css/material-overrides.css @@ -861,10 +861,6 @@ background: color-mix(in srgb, var(--c-primary) 12%, var(--c-bkg-card)) !important; } -.stream-cat-cell { - padding: 10px 16px !important; -} - .stream-cat-inner { display: flex; align-items: center; @@ -885,12 +881,9 @@ .stream-cat-label { font-weight: 600; - font-size: 0.97rem; - color: var(--c-text-base); } .stream-leaf-row td { - padding-left: 44px !important; border-top: none !important; } @@ -898,14 +891,12 @@ border-bottom: 1px solid var(--c-border); } -.stream-leaf-name a { +.stream-leaf-row .cell-name a { display: inline-flex; align-items: center; gap: 6px; } -.stream-leaf-icon { - font-size: 15px !important; - color: var(--c-text-muted); - flex-shrink: 0; +.stream-leaf-row i.material-icons { + font-size: 16px !important; } \ No newline at end of file