flat list toggle in the home list settings (#11931)

* feat: flat list toggle in the home list settings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix: reset the flat list's row cap on a view change, idle its arrows under content search

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore: drop the beta badge from the ai session greeting

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-10-21 21:20:43 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent cdaddcce59
commit 4deb4e48e7
2 changed files with 368 additions and 31 deletions
@@ -1,5 +1,4 @@
<script lang="ts">
import { Badge } from '$lib/components/common'
import { userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { takeFirstVisit, timeOfDayGreeting } from './greeting'
@@ -26,5 +25,4 @@
{greeting}{userName ? `, ${userName}` : ''}
{/if}
</p>
<Badge color="blue" small>Beta</Badge>
</div>
+368 -29
View File
@@ -57,12 +57,14 @@
import HighlightCode from '../HighlightCode.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import TreeViewRoot from './TreeViewRoot.svelte'
import Item from './Item.svelte'
import PipelineRow from '../common/table/PipelineRow.svelte'
import { TreeOpenMemory } from './treeViewState.svelte'
import { effectivePath, type ItemType } from './treeViewUtils'
import { tick, untrack } from 'svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
import BulkActionsBar from './BulkActionsBar.svelte'
import { HomeSelection, setHomeSelection } from './homeSelection.svelte'
import { HomeSelection, setHomeSelection, toBulkItem } from './homeSelection.svelte'
import HomeQuickAccess from './HomeQuickAccess.svelte'
const editRights = useEditRights()
@@ -345,6 +347,9 @@
let loading = $state(true)
// Rows the flat list shows of the loaded window.
let nbDisplayed = $state(15)
// Keyset cursor for the next browse page; null once the stream is exhausted.
let serverCursor: string | undefined = undefined
let hasMoreServer = $state(false)
@@ -806,7 +811,20 @@
return (a.ord ?? 0) - (b.ord ?? 0)
}
const FLAT_LIST_SETTING_NAME = 'homeFlatList'
let flatList = $state(getLocalSetting(FLAT_LIST_SETTING_NAME) == 'true')
function setFlatList(v: boolean) {
flatList = v
storeLocalSetting(FLAT_LIST_SETTING_NAME, v ? 'true' : undefined)
}
let listSettingsItems: MenuItem[] = $derived([
{
displayName: 'Flat list',
description: 'List items without grouping them by folder',
toggle: flatList,
action: () => setFlatList(!flatList)
},
{
displayName: 'Show library scripts',
description: 'Scripts without a main function',
@@ -1021,12 +1039,15 @@
// search (see the reload effect below). Only the label filter and fuzzy ranking
// stay client-side over the loaded pages, so they don't reload here.
let searching = $derived(filter !== '')
// Lazy owner tree is active only when browsing all (no owner selected, no search, no
// label filter): every folder AND user shows as a top-level node and paginates on
// expand, its items coming from the separate `treeOwnerItems` store. With a selected
// owner / active search / label filter, the lazy store is unused (reloadItems must not
// touch it), so this is false and the tree groups the global loaded window instead.
let treeLazyMode = $derived(!searching && ownerFilter == undefined && labelFilter == undefined)
// Lazy owner tree is active only in the tree when browsing all (no owner selected, no
// search, no label filter): every folder AND user shows as a top-level node and paginates
// on expand, its items coming from the separate `treeOwnerItems` store. In the flat list,
// or with a selected owner / active search / label filter, the lazy store is unused
// (reloadItems must not touch it), so this is false and the view reads the global loaded
// window instead.
let treeLazyMode = $derived(
!flatList && !searching && ownerFilter == undefined && labelFilter == undefined
)
// How many runnables each owner (`f/<folder>` / `u/<user>`) holds for this user,
// in one request. It labels every tree node up front — a lazy owner's own count is
// unknown until it's expanded — and lets both the tree and the owner chips drop the
@@ -1357,6 +1378,8 @@
return
mergeRunnables(res.items ?? [])
searchCursor = res.next_cursor
// Reveal the freshly fetched matches instead of leaving them behind the flat list's cap.
nbDisplayed += 30
}
let combinedItems = $derived(
@@ -1459,7 +1482,9 @@
// An archive/library/kind change keeps the same lazy tree and is applied in place
// by reloadItems re-fetching the open owners — so expanded folders don't collapse when
// you change it (searching is a boolean so per-keystroke query changes don't remount).
let treeKey = $derived(`${$workspaceStore}|${ownerFilter}|${searching}|${labelFilter}`)
let treeKey = $derived(
`${flatList}|${$workspaceStore}|${ownerFilter}|${searching}|${labelFilter}`
)
// A mode change restructures the tree (it remounts on treeKey), so the lazy owner
// store is stale — clear it here (the global reset no longer does). An in-place
// filter change leaves treeKey unchanged, so the store survives and its owners
@@ -1475,25 +1500,39 @@
ownerLoad = {}
treeOwnerItems = []
openOwners = new Set()
nbDisplayed = 15
const scoped = ownerFilter != undefined || labelFilter != undefined
if (scoped !== wasScoped) collapseAll = !scoped
wasScoped = scoped
})
})
let displayedItems = $derived((items ?? []).slice(0, nbDisplayed))
$effect(() => {
items && resetScroll()
})
let selectedIndex: number = $state(-1)
// More to show in the flat list: either loaded items not yet sliced in, or the server has
// further browse pages (not while searching — the browse cursor is paused then; further
// search matches load on demand via searchCursor / "Load more results").
let hasMore = $derived(
items != undefined && (items.length > nbDisplayed || (hasMoreServer && !searching))
)
let loadMoreIndex = $derived(displayedItems.length)
let loadMoreEl: HTMLElement | undefined = $state()
let pendingAutoSelect = $state(true)
let firstWorkspaceRun = true
$effect(() => {
$workspaceStore
pendingAutoSelect = true
if (firstWorkspaceRun) {
firstWorkspaceRun = false
return
}
// On workspace switch, melt-ui restores focus to the workspace-picker trigger
// button asynchronously after the menu closes. Run several times to win the focus
// race and hand the keyboard back to the search.
// button asynchronously after the menu closes. Without overriding it, pressing
// an arrow key would re-open / re-highlight the workspace picker instead of
// moving the flat list's selection. Run several times to win the focus race.
const focusSearch = () => {
const el = document.getElementById('home-search-input') as HTMLInputElement | null
el?.focus()
@@ -1509,35 +1548,290 @@
clearTimeout(timeoutId)
}
})
// Capture-phase listener so we run before melt-ui's button keydown handlers.
$effect(() => {
filter
itemKind
ownerFilter
labelFilter
// Skip while pendingAutoSelect is true (initial load / workspace switch);
// the auto-select effect below will set the index once items appear.
if (!pendingAutoSelect) {
selectedIndex = -1
}
})
$effect(() => {
if (pendingAutoSelect && displayedItems.length > 0) {
selectedIndex = 0
pendingAutoSelect = false
}
})
$effect(() => {
const max = hasMore ? displayedItems.length : displayedItems.length - 1
if (selectedIndex > max) {
selectedIndex = max
}
})
$effect(() => {
if (hasMore && selectedIndex === loadMoreIndex) {
loadMoreEl?.scrollIntoView({ block: 'nearest' })
}
})
// Capture-phase listener so we run before melt-ui's button keydown handlers
// (e.g. ArrowDown on the dropdown trigger would otherwise open the menu).
$effect(() => {
window.addEventListener('keydown', handleGlobalKeydown, true)
return () => window.removeEventListener('keydown', handleGlobalKeydown, true)
})
// Fetch the next server page (the tree's scoped footer and the empty-state control).
async function loadMore() {
// Fetch the next server page once all loaded items are already sliced in.
if (items && nbDisplayed >= items.length && hasMoreServer && !searching) {
await loadRunnables(false)
}
nbDisplayed += 30
}
// Fetch the next server page directly (the tree's scoped footer and the empty-state
// control): their "all shown" threshold is on a different scale than the flat list's,
// so they must not route through loadMore's item-count check.
async function fetchMoreServer() {
if (hasMoreServer && !searching) {
await loadRunnables(false)
nbDisplayed += 30
}
}
// Elements that own the keyboard themselves (menus, dialogs, comboboxes).
async function loadMoreAndPreselectFirstNew() {
const previousNbDisplayed = nbDisplayed
await loadMore()
selectedIndex = previousNbDisplayed
}
// The searchbar is a contenteditable, not an <input>, so it can't be matched by SKIP_SELECTOR
// and has no `.value`/`.selectionEnd`. It owns the arrows only while its suggestion dropdown is
// open (free-text mode passes them through to the list); track that so nav stands down then.
let searchbarDropdownOpen = $state(false)
// Caret position inside the searchbar's contenteditable, via the Selection API.
function searchCaret(el: HTMLElement): { atStart: boolean; atEnd: boolean; empty: boolean } {
const text = el.textContent ?? ''
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0)
return { atStart: true, atEnd: true, empty: text.length === 0 }
const range = sel.getRangeAt(0)
const pre = range.cloneRange()
pre.selectNodeContents(el)
pre.setEnd(range.endContainer, range.endOffset)
const caret = pre.toString().length
return { atStart: caret === 0, atEnd: caret >= text.length, empty: text.length === 0 }
}
// Elements that own the keyboard themselves (menus, dialogs, comboboxes): the
// flat list's own shortcuts stand down while one of them has focus.
const SKIP_SELECTOR =
'[role="menu"], [role="menuitem"], [role="dialog"], [role="listbox"], [role="combobox"], [aria-expanded="true"], [data-menu], [data-chat-keyboard-scope]'
// Escape leaves selection mode, unless a menu that owns the key closes itself first.
// The marker sits on the row itself, not on its title link — selection mode
// drops the link, and the action buttons must stay keyboard-reachable there too.
function getSelectedRowActionButtons(): HTMLElement[] {
const actions = document.querySelector<HTMLElement>(
'[data-row-keyboard-selected="true"] [data-row-actions]'
)
return actions ? Array.from(actions.querySelectorAll<HTMLElement>('button, a[href]')) : []
}
function handleGlobalKeydown(e: KeyboardEvent) {
// An open dialog owns the keyboard. Testing the focused element alone misses it
// (a modal opened from a button leaves focus on that button). Dialogs are in the
// DOM only while open.
// (a modal opened from a button leaves focus on that button), and this capture
// listener runs before the dialog's, so Enter would tick a row into the batch
// being confirmed. Dialogs are in the DOM only while open.
if (document.querySelector('[role="dialog"]')) return
if (e.key !== 'Escape' || !homeSelection.active) return
const target = e.target as HTMLElement | null
// Escape leaves selection mode from either view; everything below is flat-list
// navigation. A menu that owns the key closes itself first.
if (e.key === 'Escape' && homeSelection.active) {
const active = document.activeElement as HTMLElement | null
if (!target?.closest(SKIP_SELECTOR) && !active?.closest(SKIP_SELECTOR)) {
e.preventDefault()
homeSelection.exit()
return
}
}
if (!flatList || contentActive) return
// When focus is inside a row's action buttons, handle arrow keys ourselves:
// - Left/Right cycle between buttons (Left from the first returns to search).
// - Up/Down move to the same-position button on the previous/next row.
// All other keys pass through so Enter/Space activate the focused button normally.
// This must run BEFORE the skipSelector check, since the dropdown ellipsis
// trigger carries [data-menu] (which would otherwise filter the event out).
// Up/Down also need stopImmediatePropagation so melt-ui's dropdown trigger
// doesn't open the menu (its default ArrowDown behavior).
const actionsContainer = target?.closest<HTMLElement>('[data-row-actions]')
if (actionsContainer) {
if (
e.key !== 'ArrowRight' &&
e.key !== 'ArrowLeft' &&
e.key !== 'ArrowUp' &&
e.key !== 'ArrowDown'
)
return
const buttons = Array.from(actionsContainer.querySelectorAll<HTMLElement>('button, a[href]'))
const currentIdx = buttons.indexOf(target as HTMLElement)
if (currentIdx < 0) return
if (e.key === 'ArrowRight') {
if (currentIdx < buttons.length - 1) {
e.preventDefault()
buttons[currentIdx + 1].focus()
}
} else if (e.key === 'ArrowLeft') {
e.preventDefault()
if (currentIdx > 0) {
buttons[currentIdx - 1].focus()
} else {
;(document.getElementById('home-search-input') as HTMLInputElement | null)?.focus()
}
} else {
// ArrowUp / ArrowDown: move to same-position button on prev/next row.
e.preventDefault()
e.stopImmediatePropagation()
if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return
const newIndex =
e.key === 'ArrowDown'
? Math.min(selectedIndex + 1, displayedItems.length - 1)
: Math.max(selectedIndex - 1, 0)
if (newIndex === selectedIndex) return
selectedIndex = newIndex
tick().then(() => {
const newButtons = getSelectedRowActionButtons()
if (newButtons.length === 0) return
const targetIdx = Math.min(currentIdx, newButtons.length - 1)
newButtons[targetIdx]?.focus()
})
}
return
}
// Inside an open dropdown menu: ArrowUp on first item / ArrowDown on last item
// closes the menu (so users can leave with arrows instead of needing Escape).
// Other arrow keys fall through to melt-ui's default cycle.
const menuItem = target?.closest<HTMLElement>('[role="menuitem"]')
if (menuItem) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
const menu = menuItem.closest<HTMLElement>('[role="menu"]')
// menus marked data-arrow-loop keep melt's cyclic wrap instead of exiting
if (menu && !menu.hasAttribute('data-arrow-loop')) {
const items = Array.from(menu.querySelectorAll<HTMLElement>('[role="menuitem"]'))
const idx = items.indexOf(menuItem)
const isFirst = idx === 0
const isLast = idx === items.length - 1
if ((e.key === 'ArrowUp' && isFirst) || (e.key === 'ArrowDown' && isLast)) {
e.preventDefault()
e.stopImmediatePropagation()
menuItem.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
)
}
}
}
return
}
if (target) {
const tag = target.tagName
const isEditable =
tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target.isContentEditable
const isOurSearch = target.id === 'home-search-input'
if (isEditable && !isOurSearch) return
// While the searchbar's suggestion dropdown is open it owns the arrows/Enter itself.
if (isOurSearch && searchbarDropdownOpen) return
if (target.closest(SKIP_SELECTOR)) return
}
const active = document.activeElement as HTMLElement | null
if (target?.closest(SKIP_SELECTOR) || active?.closest(SKIP_SELECTOR)) return
e.preventDefault()
homeSelection.exit()
if (active?.closest(SKIP_SELECTOR)) return
// ArrowRight from search input / body → focus first action button of selected row.
// Guard: if cursor is in the middle of typed search text, let the cursor move.
if (e.key === 'ArrowRight') {
if (target?.id === 'home-search-input') {
const c = searchCaret(target)
if (!c.empty && !c.atEnd) return
}
if (selectedIndex < 0 || selectedIndex >= displayedItems.length) return
const buttons = getSelectedRowActionButtons()
if (buttons.length > 0) {
e.preventDefault()
buttons[0].focus()
}
return
}
if (e.key === 'ArrowLeft') return
if (e.key === 'ArrowDown') {
if (displayedItems.length === 0) return
e.preventDefault()
if (selectedIndex === -1) {
selectedIndex = 0
} else if (selectedIndex === loadMoreIndex && hasMore) {
selectedIndex = 0
} else if (selectedIndex === displayedItems.length - 1) {
selectedIndex = hasMore ? loadMoreIndex : 0
} else {
selectedIndex = selectedIndex + 1
}
} else if (e.key === 'ArrowUp') {
if (displayedItems.length === 0) return
e.preventDefault()
if (selectedIndex === -1) {
selectedIndex = displayedItems.length - 1
} else if (selectedIndex === loadMoreIndex && hasMore) {
selectedIndex = displayedItems.length - 1
} else if (selectedIndex === 0) {
selectedIndex = hasMore ? loadMoreIndex : displayedItems.length - 1
} else {
selectedIndex = selectedIndex - 1
}
} else if (e.key === 'Enter') {
// Enter belongs to whatever control has focus — the action bar's buttons,
// a row's own actions, a link. Claiming it there would both suppress that
// control and act on the highlighted row instead.
if (target?.closest('button, a[href], [role="button"]')) return
// In selection mode the rows carry no link, so Enter ticks the highlighted
// row instead of opening it. Never for a legacy raw-app row, which carries
// no selection control — that falls through to opening it below.
if (
homeSelection.active &&
selectedIndex >= 0 &&
selectedIndex < displayedItems.length &&
displayedItems[selectedIndex].type !== 'raw_app'
) {
e.preventDefault()
homeSelection.toggle(
toBulkItem(displayedItems[selectedIndex], $userStore, $workspaceStore),
e.shiftKey
)
} else if (selectedIndex === loadMoreIndex && hasMore) {
e.preventDefault()
loadMoreAndPreselectFirstNew()
} else if (selectedIndex >= 0 && selectedIndex < displayedItems.length) {
// Direct child only: that is the title link. Selection mode drops it, and
// a descendant match would find the row's Edit link and open the editor.
const anchor = document.querySelector<HTMLAnchorElement>(
'[data-row-keyboard-selected="true"] > a[href]'
)
if (anchor) {
e.preventDefault()
anchor.click()
}
}
} else if (e.key === 'Escape') {
if (selectedIndex !== -1) {
e.preventDefault()
selectedIndex = -1
}
}
}
// Multi-selection + bulk actions. Published through context so the tree's
// nested levels don't have to carry it; `Item` is the only reader.
@@ -1632,6 +1926,7 @@
autofocus
hideDropdownOnFreeText
inputId="home-search-input"
onDropdownVisibleChange={(v) => (searchbarDropdownOpen = v)}
/>
</div>
{#if !contentActive}
@@ -1661,14 +1956,16 @@
</div>
{/if}
{#if !contentActive && listControls}
<Button
unifiedSize="md"
variant="default"
iconOnly
title={collapseAll ? 'Expand all' : 'Collapse all'}
on:click={toggleCollapseAll}
startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }}
/>
{#if !flatList}
<Button
unifiedSize="md"
variant="default"
iconOnly
title={collapseAll ? 'Expand all' : 'Collapse all'}
on:click={toggleCollapseAll}
startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }}
/>
{/if}
<DropdownV2 items={listSettingsItems} placement="bottom-end" fixedHeight={false}>
{#snippet buttonReplacement()}
<Button
@@ -1801,8 +2098,50 @@
</div>
{/if}
</div>
{:else if flatList}
<!-- Every row draws its own border-b; pulling the content 1px down hides the last
one under the clip, as the tree does. -->
<div class="overflow-hidden">
<div class="-mb-px">
{#if filter === ''}
{#each [...visiblePipelineFolders].sort() as folder (folder)}
<PipelineRow {folder} onDeleted={reloadItemsAndCounts} />
{/each}
{/if}
{#each displayedItems as item, i (item.type + '/' + item.path + (item.hash ? '/' + item.hash : ''))}
<Item
{item}
on:scriptChanged={reloadItemsAndCounts}
on:flowChanged={reloadItemsAndCounts}
on:appChanged={reloadItemsAndCounts}
on:rawAppChanged={reloadItemsAndCounts}
on:reload={reloadItemsAndCounts}
{showCode}
showEditButton={showEditButtons}
keyboardSelected={selectedIndex === i}
/>
{/each}
{#if items && hasMore}
<div
bind:this={loadMoreEl}
class="px-4 py-3 flex flex-row items-center justify-between gap-4 {selectedIndex ===
loadMoreIndex
? 'bg-surface-hover'
: 'bg-surface-secondary'}"
>
<span class="text-xs text-secondary">
Showing {Math.min(nbDisplayed, items.length)}{(hasMoreServer && !searching) ||
(searching && searchCursor)
? ''
: ` of ${items.length}`} items
</span>
<Button unifiedSize="sm" variant="subtle" on:click={loadMore}>Load more</Button>
</div>
{/if}
</div>
</div>
{:else}
<!-- Remount the tree on a MODE change only (treeKey = owner/search/label):
<!-- Remount the tree on a MODE change only (treeKey = view/owner/search/label):
expanded folders (their `opened` state is local to each TreeView) collapse
and re-load fresh for the new mode. An in-place archive/library/kind
change keeps treeKey stable, so folders stay open and refresh via