feat: home list settings menu with sort, library and user-folder toggles (#11928)

* feat: restore home sort order and library script toggle in a list settings menu

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

* feat: hide other users' folders from the list settings, remember open folders and list settings

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 20:58:44 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d98a26b1c8
commit 73f655db98
4 changed files with 171 additions and 21 deletions
+119 -18
View File
@@ -27,12 +27,13 @@
Code2,
LayoutDashboard,
MoreHorizontal,
Settings,
Tag
} from 'lucide-svelte'
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import CreateActionsMenu from './CreateActionsMenu.svelte'
import ContentSearchInner from '$lib/components/ContentSearchInner.svelte'
import type { IconType } from '$lib/utils'
import type { IconType, Item as MenuItem } from '$lib/utils'
import { HOME_COLUMN_WIDTH, HOME_SEARCH_SHOW_FLOW, HOME_SEARCH_PLACEHOLDER } from '$lib/consts'
@@ -50,12 +51,13 @@
import { clampRows } from './clampRows'
import { setUnruledList } from '../common/table/unruledList'
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
import { isOwner } from '$lib/utils'
import { getLocalSetting, isOwner, storeLocalSetting } from '$lib/utils'
import { sendUserToast } from '$lib/toast'
import Drawer from '../common/drawer/Drawer.svelte'
import HighlightCode from '../HighlightCode.svelte'
import DrawerContent from '../common/drawer/DrawerContent.svelte'
import TreeViewRoot from './TreeViewRoot.svelte'
import { TreeOpenMemory } from './treeViewState.svelte'
import { effectivePath, type ItemType } from './treeViewUtils'
import { tick, untrack } from 'svelte'
import { triggerableByAI } from '$lib/actions/triggerableByAI.svelte'
@@ -167,7 +169,17 @@
}
// Single URL-synced source of truth for the searchbar-driven filters.
let filterValues = useUrlSyncedFilterInstance(untrack(() => searchFilterSchema))
// The library and user-folder choices outlive the URL: a link that sets one still wins,
// but a bare visit restores the last one made in this browser.
const HIDE_LIBRARY_SETTING_NAME = 'homeHideLibrary'
const ONLY_USER_FOLDERS_SETTING_NAME = 'homeOnlyUserFolders'
let filterValues = useUrlSyncedFilterInstance(
untrack(() => searchFilterSchema),
{
include_library: getLocalSetting(HIDE_LIBRARY_SETTING_NAME) == 'true' ? false : undefined,
only_user_folders: getLocalSetting(ONLY_USER_FOLDERS_SETTING_NAME) == 'true' || undefined
}
)
// Derived views the rest of the data layer reads. The merged-endpoint reload effect
// depends on these (search, kind, archived, library, user-folder scope), so changing a
@@ -179,7 +191,18 @@
let runnableKinds = $derived(itemKind === 'all' ? undefined : itemKind)
let archived = $derived(!!filterValues.val.archived)
let includeWithoutMain = $derived((filterValues.val.include_library ?? true) as boolean)
let filterUserFolders = $derived(!!filterValues.val.only_user_folders)
// Gated on the role so a choice remembered from an admin session can't hide other users'
// spaces for a role that is never offered the toggle.
let filterUserFolders = $derived(!!filterValues.val.only_user_folders && !!filterUserFoldersType)
$effect(() => {
storeLocalSetting(HIDE_LIBRARY_SETTING_NAME, includeWithoutMain ? undefined : 'true')
})
$effect(() => {
storeLocalSetting(
ONLY_USER_FOLDERS_SETTING_NAME,
filterValues.val.only_user_folders ? 'true' : undefined
)
})
// Content search is a distinct mode: its results come from ContentSearchInner
// (which carries only path + content), so the row-list filters can't
@@ -374,14 +397,13 @@
}
const gen = ++loadGen
// Snapshot the cursor now: a later request must not consume a cursor minted
// by a filter that has since changed.
// by an order/filter that has since changed.
const cursor = reset ? undefined : serverCursor
let res: { items: RunnableItem[]; next_cursor?: string }
try {
res = await ScriptService.listRunnables({
workspace: ws,
orderBy: 'updated',
orderDesc: true,
...sortToParams(sortOrder),
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
kinds: runnableKinds,
@@ -601,8 +623,7 @@
try {
res = await ScriptService.listRunnables({
workspace: ws,
orderBy: 'updated',
orderDesc: true,
...sortToParams(sortOrder),
showArchived: archived ? true : undefined,
includeWithoutMain: includeWithoutMain ? true : undefined,
kinds: runnableKinds,
@@ -752,15 +773,75 @@
const cmp = new Intl.Collator('en').compare
// Preserve the endpoint's exact order (most recently updated first, starred pinned on top
// of the first page) rather than re-deriving it on the client: each row carries its server
// fetch ordinal (`ord`), which already reflects the (path, kind) tiebreaks, full-precision
// timestamps and the database collation. Sorting by it can never disagree with the server,
// so a later page never jumps above shown rows on "load more".
// The order is applied server-side so it holds across the whole workspace, not just the
// loaded pages. Starred items are pinned on top of the first page.
type SortOrder = 'updated_desc' | 'updated_asc' | 'name_asc' | 'name_desc'
const SORT_SETTING_NAME = 'homeSort'
const sortOptions: { value: SortOrder; label: string }[] = [
{ value: 'updated_desc', label: 'Recently updated' },
{ value: 'updated_asc', label: 'Oldest updated' },
{ value: 'name_asc', label: 'Name (A-Z)' },
{ value: 'name_desc', label: 'Name (Z-A)' }
]
let sortOrder = $state<SortOrder>(
sortOptions.find((o) => o.value === getLocalSetting(SORT_SETTING_NAME))?.value ?? 'updated_desc'
)
function setSortOrder(o: SortOrder) {
sortOrder = o
storeLocalSetting(SORT_SETTING_NAME, o === 'updated_desc' ? undefined : o)
}
function sortToParams(o: SortOrder): { orderBy: 'updated' | 'name'; orderDesc: boolean } {
return {
orderBy: o.startsWith('name') ? 'name' : 'updated',
orderDesc: o.endsWith('desc')
}
}
// Preserve the endpoint's exact order rather than re-deriving it on the client: each row
// carries its server fetch ordinal (`ord`), which already reflects the chosen order, the
// (path, kind) tiebreaks, full-precision timestamps and the database collation. Sorting by
// it can never disagree with the server, so a later page never jumps above shown rows on
// "load more".
function compareItems(a: { ord?: number }, b: { ord?: number }): number {
return (a.ord ?? 0) - (b.ord ?? 0)
}
let listSettingsItems: MenuItem[] = $derived([
{
displayName: 'Show library scripts',
description: 'Scripts without a main function',
toggle: includeWithoutMain,
hide: !($userStore && $roleCapabilities.code),
action: () => {
// On by default, so showing them again clears the key rather than leaving a chip.
if (includeWithoutMain) filterValues.val.include_library = false
else delete filterValues.val.include_library
}
},
{
displayName:
filterUserFoldersType === 'only f/*' ? 'Hide user folders' : "Hide other users' folders",
description:
filterUserFoldersType === 'only f/*'
? 'Only f/*'
: `Only u/${$userStore?.username} and f/*`,
toggle: filterUserFolders,
hide: !filterUserFoldersType,
action: () => {
if (filterUserFolders) delete filterValues.val.only_user_folders
else filterValues.val.only_user_folders = true
}
},
...sortOptions.map((o, i) => ({
displayName: o.label,
selected: o.value === sortOrder,
separatorTop: i === 0,
disabled: filter !== '',
tooltip: filter !== '' ? 'Search results are ranked by relevance' : undefined,
action: () => setSortOrder(o.value)
}))
])
const opts: uFuzzy.Options = {
sort: (info, haystack, needle) => {
let {
@@ -850,6 +931,12 @@
}
let collapseAll = $state(true)
let treeOpenMemory = $derived(new TreeOpenMemory(`homeTreeOpen:${$workspaceStore}`))
function toggleCollapseAll() {
// Collapsing all is also how to forget the folders reopened on every visit.
if (!collapseAll) treeOpenMemory.clear()
collapseAll = !collapseAll
}
// Human-readable list of the filters currently narrowing the list. Empty means no
// filter is active, so an empty result then means the workspace itself has no items
// (NoItemFound shows the welcome message); otherwise the filters are just too narrow
@@ -1160,9 +1247,9 @@
let treeGlobalHasMore = $derived(!treeLazyMode && !searching ? hasMoreServer : false)
$effect(() => {
if ($userStore && $workspaceStore) {
;[archived, includeWithoutMain, searching, ownerFilter, itemKind]
// reloadItems (not a bare loadRunnables) so an in-place change — archive, library,
// kind — reloads the global stream AND re-fetches the currently-open owners
;[archived, includeWithoutMain, sortOrder, searching, ownerFilter, itemKind]
// reloadItems (not a bare loadRunnables) so an in-place change — sort, archive,
// library, kind — reloads the global stream AND re-fetches the currently-open owners
// under the new filter, keeping folders expanded (file-explorer style)
// rather than collapsing them. Mode changes (owner/search) flip treeLazyMode, so
// reloadItems skips the re-fetch and the {#key} remount handles the reset.
@@ -1579,9 +1666,21 @@
variant="default"
iconOnly
title={collapseAll ? 'Expand all' : 'Collapse all'}
on:click={() => (collapseAll = !collapseAll)}
on:click={toggleCollapseAll}
startIcon={{ icon: collapseAll ? ChevronsUpDown : ChevronsDownUp }}
/>
<DropdownV2 items={listSettingsItems} placement="bottom-end" fixedHeight={false}>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
unifiedSize="md"
variant="default"
iconOnly
title="List settings"
startIcon={{ icon: Settings }}
/>
{/snippet}
</DropdownV2>
{/if}
<!-- Same gate the old create actions used: hidden from operators and in workspaces
whose direct-deploy protection cleared showEditButtons (NoDirectDeployAlert), since
@@ -1713,6 +1812,8 @@
items={treeSource}
{collapseAll}
sortCompare={compareItems}
groupDesc={sortOrder === 'name_desc'}
openMemory={treeOpenMemory}
hasMoreServer={treeGlobalHasMore}
onLoadMore={fetchMoreServer}
pipelineFolders={visiblePipelineFolders}
@@ -11,6 +11,7 @@
import { pluralize } from '$lib/utils'
import { Button } from '$lib/components/common'
import { inUnruledList, rowRule } from '../common/table/unruledList'
import type { TreeOpenMemory } from './treeViewState.svelte'
interface Props {
item: ItemType | FolderItem | UserItem
@@ -52,6 +53,9 @@
// Renders a leaf in place of the home page's `<Item>`, for lists whose rows are not
// scripts/flows/apps. Receives the leaf and its visual depth.
leaf?: Snippet<[ItemType, number]>
// Remembers which nodes the user opened by hand, so they reopen on the next visit
// while expand/collapse-all is in its collapsed state.
openMemory?: TreeOpenMemory
}
let {
@@ -70,7 +74,8 @@
parentPrefix,
ancestorHasMore = false,
indent = 0,
leaf
leaf,
openMemory
}: Props = $props()
const unruled = inUnruledList()
@@ -208,7 +213,8 @@
ownerKey != undefined &&
ownerLoad != undefined &&
rootIndex >= EXPAND_ALL_LOAD_LIMIT
const shouldOpen = expandAll && !cappedOwner
const remembered = !expandAll && nodePrefix != undefined && !!openMemory?.isOpen(nodePrefix)
const shouldOpen = (expandAll && !cappedOwner) || remembered
opened = shouldOpen
if (ownerKey == undefined) return
if (shouldOpen) {
@@ -244,6 +250,7 @@
if (now - lastToggle < 300) return
lastToggle = now
opened = !opened
if (nodePrefix != undefined) openMemory?.setOpen(nodePrefix, opened)
if (opened) {
// A top-level owner loads on expand. A subfolder's rows come from an ancestor's
// pages, so opening one must not fire a request per subfolder — its own "Load
@@ -313,6 +320,7 @@
{onCollapseOwner}
parentPrefix={nodePrefix}
ancestorHasMore={nodeHasMore}
{openMemory}
on:scriptChanged
on:flowChanged
on:appChanged
@@ -7,6 +7,7 @@
import TreeOwnerRow from './TreeOwnerRow.svelte'
import { inUnruledList, rowRule } from '../common/table/unruledList'
import { getLocalSetting, pluralize, storeLocalSetting } from '$lib/utils'
import type { TreeOpenMemory } from './treeViewState.svelte'
interface Props {
collapseAll: boolean
@@ -50,6 +51,8 @@
groupOtherUsers?: boolean
// See TreeView's `leaf`.
leaf?: Snippet<[ItemType, number]>
// See TreeView's `openMemory`.
openMemory?: TreeOpenMemory
}
let {
@@ -72,7 +75,8 @@
showEditButton = true,
framed = true,
groupOtherUsers = false,
leaf
leaf,
openMemory
}: Props = $props()
const unruled = inUnruledList()
@@ -271,6 +275,7 @@
{showCode}
{showEditButton}
{leaf}
{openMemory}
/>
{/snippet}
@@ -20,3 +20,39 @@ export class TreeViewState {
storeLocalSetting(this.#settingName, value ? 'true' : undefined)
}
}
/** The tree nodes a user opened by hand, by full path prefix, persisted under `settingName`. */
export class TreeOpenMemory {
// Bounds what a long-lived browser accumulates; the oldest openings drop first.
static readonly MAX = 200
#settingName: string
#open: string[]
constructor(settingName: string) {
this.#settingName = settingName
let stored: unknown
try {
stored = JSON.parse(getLocalSetting(settingName) ?? '[]')
} catch {}
this.#open = Array.isArray(stored) ? stored.filter((k) => typeof k === 'string') : []
}
isOpen(prefix: string): boolean {
return this.#open.includes(prefix)
}
setOpen(prefix: string, open: boolean) {
this.#open = this.#open.filter((k) => k !== prefix)
if (open) this.#open = [...this.#open, prefix].slice(-TreeOpenMemory.MAX)
this.#save()
}
clear() {
this.#open = []
this.#save()
}
#save() {
storeLocalSetting(this.#settingName, this.#open.length ? JSON.stringify(this.#open) : undefined)
}
}