mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-23 00:03:46 +00:00
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:
co-authored by
Claude Opus 5.5
parent
cdaddcce59
commit
4deb4e48e7
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user