From 73f655db98dd0f0fd03881c36e6c9310fb2687a2 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Wed, 21 Oct 2026 20:58:44 +0200 Subject: [PATCH] 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 * feat: hide other users' folders from the list settings, remember open folders and list settings Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../src/lib/components/home/ItemsList.svelte | 137 +++++++++++++++--- .../src/lib/components/home/TreeView.svelte | 12 +- .../lib/components/home/TreeViewRoot.svelte | 7 +- .../components/home/treeViewState.svelte.ts | 36 +++++ 4 files changed, 171 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 6b9ee2fc50..29a2362691 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -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( + 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 }} /> + + {#snippet buttonReplacement()} +