From 02ae2b4c33db8c06e15805db0e1957c5596d6418 Mon Sep 17 00:00:00 2001 From: Waleed Date: Wed, 19 Aug 2026 18:52:59 -0700 Subject: [PATCH] feat(sidebar): add Tables and Files flyouts to the collapsed rail (#6882) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sidebar): add Tables and Files flyouts to the collapsed rail Chats and Workflows already open a hover flyout on the collapsed rail; Tables and Files were plain links. Both now list their contents, with folders as submenus and the open resource marked. The chip stays a real link, so clicking still opens the list page and right-click still reaches the nav context menu. Each flyout owns its queries and mounts only when the menu opens: a hook on the sidebar keeps its cache subscription on every workspace route even when disabled, so an unrelated writer would re-render the whole sidebar for a closed flyout. Rows are ordered by the shared sortResources, so pinned rows float and the flyout reads in the same order as the page it links into. Also removes two dead components (CollapsedFileFolderItems, FileList) that were exported but never rendered, and extracts SidebarNavChip so the rail chip has one definition. * fix(emcn): stop ordinary menus scrolling at the shared height cap Every DropdownMenuContent was capped at a flat 240px. A menu is 28px per row, 13px per separator, plus 12px padding, so a 7-row action menu with 3 separators measures 247px and scrolled for 7px while the 7-row menu beside it with 1 separator did not. Raises the cap to 420px, which clears every hand-authored action menu, and clamps it with min() against the space Radix measures so a menu near a viewport edge stays on screen — which the flat value never did. The cap still exists so a long data-driven list scrolls instead of running the height of the screen. * fix(sidebar): hold the rail flyout until its lists resolve for this workspace Both the resource and folder queries keep the previous workspace's rows as placeholder data across a switch. Gating only on isPending let the flyout build a tree from one workspace's resources against another's folders, where no folder id resolves — which the builder reads as "archived out from under it" and files the whole list at the root. Gate on isPlaceholderData too, matching foldersResolved in use-folder-ancestors. An error settles a query without resolving it and is deliberately not held: the flyout then renders flat, which still reaches every row. * improvement(sidebar): mark pinned rows in the rail flyout The flyout sorts pinned rows to the top via the shared sortResources, but rendered no indicator, so that ordering read as arbitrary — the exact pairing Resource's own label cell documents. Carry `pinned` on each row and render the same non-interactive glyph, on folders as well as resources. Adds folder-structure coverage alongside it: per-level ordering, the full depth of a nested chain, and an empty folder staying in the tree. --- .../components/folders/flyout-entries.test.ts | 197 ++++++++++++ .../components/folders/flyout-entries.ts | 112 +++++++ .../components/folders/foldered-resources.ts | 4 +- .../[workspaceId]/components/folders/index.ts | 2 + .../collapsed-sidebar-menu.test.tsx | 153 +++++++++ .../collapsed-sidebar-menu.tsx | 258 +++++++++------ .../collapsed-sidebar-menu/index.ts | 2 +- .../components/file-list/file-list.tsx | 162 ---------- .../sidebar/components/file-list/index.ts | 1 - .../w/components/sidebar/components/index.ts | 6 +- .../components/rail-resource-flyout/index.ts | 1 + .../rail-resource-flyout.test.tsx | 133 ++++++++ .../rail-resource-flyout.tsx | 106 +++++++ .../components/sidebar-nav-chip/index.ts | 2 + .../sidebar-nav-chip/sidebar-nav-chip.tsx | 84 +++++ .../w/components/sidebar/sidebar.tsx | 299 +++++++++--------- .../dropdown-menu/dropdown-menu.tsx | 20 +- 17 files changed, 1119 insertions(+), 423 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/rail-resource-flyout/rail-resource-flyout.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip/sidebar-nav-chip.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts new file mode 100644 index 0000000000..6645f469cd --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.test.ts @@ -0,0 +1,197 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { buildFlyoutEntries } from '@/app/workspace/[workspaceId]/components/folders/flyout-entries' + +function folder(id: string, name: string, parentId: string | null, updatedAt: string) { + return { id, name, parentId, updatedAt: new Date(updatedAt) } +} + +function item(id: string, name: string, folderId: string | null, updatedAt: string) { + return { id, name, folderId, updatedAt: new Date(updatedAt) } +} + +const NONE: ReadonlySet = new Set() + +function build( + folders: ReturnType[], + items: ReturnType[], + pinned?: { folders?: ReadonlySet; items?: ReadonlySet } +) { + return buildFlyoutEntries({ + folders, + items, + pinnedFolderIds: pinned?.folders ?? NONE, + pinnedItemIds: pinned?.items ?? NONE, + hrefForItem: (row) => `/x/${row.id}`, + }) +} + +describe('buildFlyoutEntries', () => { + it('orders folders and items together, most-recently-updated first', () => { + const entries = build( + [ + folder('f1', 'Older folder', null, '2026-01-01'), + folder('f2', 'Newest', null, '2026-03-01'), + ], + [item('i1', 'Middle', null, '2026-02-01')] + ) + + expect(entries.map((entry) => entry.id)).toEqual(['f2', 'i1', 'f1']) + }) + + it('floats pinned rows above newer unpinned ones, matching the list pages', () => { + const entries = build( + [folder('f1', 'Folder', null, '2026-03-01')], + [item('i1', 'Pinned', null, '2026-01-01'), item('i2', 'Newest', null, '2026-04-01')], + { items: new Set(['i1']) } + ) + + expect(entries.map((entry) => entry.id)).toEqual(['i1', 'i2', 'f1']) + }) + + it('breaks ties on name', () => { + const entries = build( + [], + [ + item('b', 'Beta', null, '2026-01-01'), + item('c', 'Alpha', null, '2026-01-01'), + item('a', 'Gamma', null, '2026-01-01'), + ] + ) + + expect(entries.map((entry) => entry.id)).toEqual(['c', 'b', 'a']) + }) + + it('nests items under their folder and links each one', () => { + const entries = build( + [folder('f1', 'Reports', null, '2026-01-01'), folder('f2', 'Q1', 'f1', '2026-01-02')], + [item('i1', 'Revenue', 'f2', '2026-01-03')] + ) + + expect(entries).toEqual([ + { + kind: 'folder', + id: 'f1', + name: 'Reports', + pinned: false, + children: [ + { + kind: 'folder', + id: 'f2', + name: 'Q1', + pinned: false, + children: [{ kind: 'item', id: 'i1', name: 'Revenue', pinned: false, href: '/x/i1' }], + }, + ], + }, + ]) + }) + + it('hoists a folder and an item whose parent folder is gone to the root', () => { + const entries = build( + [folder('f1', 'Orphan', 'archived-folder', '2026-01-02')], + [item('i1', 'Loose', 'archived-folder', '2026-01-01')] + ) + + expect(entries.map((entry) => entry.id)).toEqual(['f1', 'i1']) + expect(entries[0]).toMatchObject({ kind: 'folder', children: [] }) + }) + + it('drops folders reachable only through a parent cycle instead of descending it', () => { + const entries = build( + [ + folder('a', 'A', 'b', '2026-01-01'), + folder('b', 'B', 'a', '2026-01-01'), + folder('root', 'Root', null, '2026-01-01'), + ], + [] + ) + + expect(entries.map((entry) => entry.id)).toEqual(['root']) + }) + + it('accepts serialized date strings and sorts undated rows last', () => { + const entries = buildFlyoutEntries({ + folders: [], + items: [ + { id: 'i1', name: 'Undated', folderId: null, updatedAt: 'not-a-date' }, + { id: 'i2', name: 'Dated', folderId: null, updatedAt: '2026-01-01T00:00:00.000Z' }, + ], + pinnedFolderIds: NONE, + pinnedItemIds: NONE, + hrefForItem: (row) => `/x/${row.id}`, + }) + + expect(entries.map((entry) => entry.id)).toEqual(['i2', 'i1']) + }) + + it('treats a missing folderId as the root', () => { + const entries = buildFlyoutEntries({ + folders: [], + items: [{ id: 'i1', name: 'Rootless', updatedAt: new Date('2026-01-01') }], + pinnedFolderIds: NONE, + pinnedItemIds: NONE, + hrefForItem: (row) => `/x/${row.id}`, + }) + + expect(entries).toEqual([ + { kind: 'item', id: 'i1', name: 'Rootless', pinned: false, href: '/x/i1' }, + ]) + }) + + it('keeps each nesting level ordered independently, not just the root', () => { + const entries = build( + [folder('f1', 'Root folder', null, '2026-05-01')], + [ + item('deep-old', 'Deep old', 'f1', '2026-01-01'), + item('deep-new', 'Deep new', 'f1', '2026-04-01'), + item('root-mid', 'Root mid', null, '2026-03-01'), + ] + ) + + expect(entries.map((entry) => entry.id)).toEqual(['f1', 'root-mid']) + const nested = entries[0] + expect(nested.kind).toBe('folder') + if (nested.kind !== 'folder') throw new Error('expected a folder') + expect(nested.children.map((child) => child.id)).toEqual(['deep-new', 'deep-old']) + }) + + it('preserves the full depth of the folder chain', () => { + const entries = build( + [ + folder('a', 'A', null, '2026-01-01'), + folder('b', 'B', 'a', '2026-01-01'), + folder('c', 'C', 'b', '2026-01-01'), + ], + [item('leaf', 'Leaf', 'c', '2026-01-01')] + ) + + const depth = (rows: ReturnType): number => { + const nested = rows.find((row) => row.kind === 'folder') + return nested && nested.kind === 'folder' ? 1 + depth(nested.children) : 0 + } + expect(depth(entries)).toBe(3) + }) + + it('keeps an empty folder in the tree rather than dropping it', () => { + const entries = build( + [folder('empty', 'Nothing here', null, '2026-01-01')], + [item('i1', 'Loose', null, '2026-01-02')] + ) + + expect(entries.map((entry) => entry.id)).toEqual(['i1', 'empty']) + expect(entries[1]).toMatchObject({ kind: 'folder', children: [] }) + }) + + it('marks pinned folders and pinned resources so the ordering is legible', () => { + const entries = build( + [folder('f1', 'Folder', null, '2026-01-01')], + [item('i1', 'Table', null, '2026-01-02')], + { folders: new Set(['f1']), items: new Set(['i1']) } + ) + + expect(entries.map((entry) => entry.pinned)).toEqual([true, true]) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts new file mode 100644 index 0000000000..d9ba6729ef --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/flyout-entries.ts @@ -0,0 +1,112 @@ +import { + type SortableResource, + sortResources, +} from '@/app/workspace/[workspaceId]/components/folders/resource-sort' + +/** A folder row a resource flyout can render, from any foldered workspace surface. */ +interface FlyoutFolderSource { + id: string + name: string + parentId: string | null + updatedAt: Date | string +} + +/** A resource row a flyout can render, from any foldered workspace surface. */ +interface FlyoutItemSource { + id: string + name: string + folderId?: string | null + updatedAt: Date | string +} + +/** One row of a resource flyout: a folder that recurses, or a linked resource. */ +export type FlyoutEntry = + | { kind: 'folder'; id: string; name: string; pinned: boolean; children: FlyoutEntry[] } + | { kind: 'item'; id: string; name: string; pinned: boolean; href: string } + +export interface BuildFlyoutEntriesParams { + folders: FlyoutFolderSource[] + items: Item[] + pinnedFolderIds: ReadonlySet + pinnedItemIds: ReadonlySet + hrefForItem: (item: Item) => string +} + +function flyoutSortTime(value: Date | string): number { + const time = value instanceof Date ? value.getTime() : Date.parse(value) + return Number.isNaN(time) ? 0 : time +} + +/** + * Builds the ordered row tree a foldered resource's flyout renders. + * + * Each level is sorted by the shared {@link sortResources}, on the most-recently-updated + * key its list page defaults to — so pinned rows float, folders interleave with the + * resources beside them, and the flyout keeps reading in the same order as the page it + * links into rather than carrying a second copy of that rule. `pinned` rides along on each + * row because that ordering reads as arbitrary without the indicator the rows render from + * it — the same pairing `Resource`'s own cells make. + * + * A folder whose parent no longer exists, and a resource whose `folderId` names no live + * folder, surface at the root — the same fallback the list pages apply when a folder is + * archived out from under its contents, so neither goes unreachable. A folder only + * reachable through a parent cycle is dropped, as it is by the sidebar's folder tree: the + * client folder cache is written optimistically, so a cycle is reachable there even though + * the server rejects one, and descending it would hang the tab. + */ +export function buildFlyoutEntries({ + folders, + items, + pinnedFolderIds, + pinnedItemIds, + hrefForItem, +}: BuildFlyoutEntriesParams): FlyoutEntry[] { + const folderIds = new Set(folders.map((folder) => folder.id)) + + const foldersByParent = new Map() + for (const folder of folders) { + const parentId = folder.parentId && folderIds.has(folder.parentId) ? folder.parentId : null + const siblings = foldersByParent.get(parentId) + if (siblings) siblings.push(folder) + else foldersByParent.set(parentId, [folder]) + } + + const itemsByFolder = new Map() + for (const item of items) { + const folderId = item.folderId && folderIds.has(item.folderId) ? item.folderId : null + const siblings = itemsByFolder.get(folderId) + if (siblings) siblings.push(item) + else itemsByFolder.set(folderId, [item]) + } + + const buildLevel = (parentId: string | null): FlyoutEntry[] => { + const rows: SortableResource[] = [] + for (const folder of foldersByParent.get(parentId) ?? []) { + const pinned = pinnedFolderIds.has(folder.id) + rows.push({ + item: { + kind: 'folder', + id: folder.id, + name: folder.name, + pinned, + children: buildLevel(folder.id), + }, + pinned, + name: folder.name, + key: flyoutSortTime(folder.updatedAt), + }) + } + for (const item of itemsByFolder.get(parentId) ?? []) { + const pinned = pinnedItemIds.has(item.id) + rows.push({ + item: { kind: 'item', id: item.id, name: item.name, pinned, href: hrefForItem(item) }, + pinned, + name: item.name, + key: flyoutSortTime(item.updatedAt), + }) + } + return sortResources(rows, 'desc').map((row) => row.item) + } + + return buildLevel(null) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts index f370e98762..45ea37ead2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts @@ -1,4 +1,4 @@ -import type { ElementType } from 'react' +import type { ComponentType } from 'react' import { Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons' import type { FolderResourceType } from '@/lib/api/contracts/folders' import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders/search-params' @@ -17,7 +17,7 @@ export interface FolderedResourceHeaderMeta { /** Root crumb label, and the page title at the workspace root. */ rootLabel: string /** Icon on the root crumb, which is also what opens the header's "Path" popover. */ - rootIcon: ElementType + rootIcon: ComponentType<{ className?: string }> /** Path segment of the list page under `/workspace/[workspaceId]/`. */ listSegment: string } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index 93f2ecf13f..fb012cfb42 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -1,4 +1,6 @@ export { readRowDragPayload, writeRowDragPayload } from './drag-payload' +export type { BuildFlyoutEntriesParams, FlyoutEntry } from './flyout-entries' +export { buildFlyoutEntries } from './flyout-entries' export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs' export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs' export { FolderContextMenu } from './folder-context-menu' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx new file mode 100644 index 0000000000..f786ac6b5e --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.test.tsx @@ -0,0 +1,153 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) + +import { Table } from '@sim/emcn/icons' +import { + CollapsedResourceFlyout, + CollapsedSidebarMenu, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu' + +function stubHoverMenu(isOpen: boolean) { + return { + isOpen, + open: vi.fn(), + close: vi.fn(), + setLocked: vi.fn(), + triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() }, + contentProps: { + onMouseEnter: vi.fn(), + onMouseLeave: vi.fn(), + onCloseAutoFocus: vi.fn(), + }, + } as unknown as Parameters[0]['hover'] +} + +describe('CollapsedSidebarMenu nav-link trigger', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + + function renderMenu( + options: { isOpen?: boolean; onContextMenu?: (e: unknown, href: string) => void } = {} + ) { + act(() => { + root.render( + + + + ) + }) + const trigger = container.querySelector('a') + if (!trigger) throw new Error('trigger anchor not rendered') + return trigger + } + + it('renders the rail chip as a real link, not the primitive button', () => { + const trigger = renderMenu() + + expect(trigger.getAttribute('href')).toBe('/workspace/w1/tables') + expect(trigger.textContent).toContain('Tables') + expect(container.querySelector('button')).toBeNull() + /* Radix's trigger is a button primitive; its `type` must not leak onto the anchor. */ + expect(trigger.hasAttribute('type')).toBe(false) + }) + + it('activates the link on Enter, which Radix would otherwise swallow to toggle the menu', () => { + const trigger = renderMenu() + const onClick = vi.fn((e: Event) => e.preventDefault()) + trigger.addEventListener('click', onClick) + + act(() => { + trigger.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ) + }) + + expect(onClick).toHaveBeenCalledTimes(1) + }) + + it('forwards a right-click to the nav item context menu with its href', () => { + const onContextMenu = vi.fn() + const trigger = renderMenu({ onContextMenu }) + + act(() => { + trigger.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })) + }) + + expect(onContextMenu).toHaveBeenCalledWith(expect.anything(), '/workspace/w1/tables') + }) + + it('lists the resource rows once the flyout is open', () => { + renderMenu({ isOpen: true }) + + const row = document.querySelector('a[href="/workspace/w1/tables/t1"]') + expect(row?.textContent).toContain('Leads') + }) + + it('marks a pinned row, so sorting it to the top does not read as arbitrary', () => { + renderMenu({ isOpen: true }) + + const pinnedRow = document.querySelector('a[href="/workspace/w1/tables/t2"]') + const plainRow = document.querySelector('a[href="/workspace/w1/tables/t1"]') + expect(pinnedRow?.querySelector('[aria-label="Pinned"]')).not.toBeNull() + expect(plainRow?.querySelector('[aria-label="Pinned"]')).toBeNull() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index 831b3b0f7d..f48a6c1eb8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -1,5 +1,6 @@ -import { type MouseEvent as ReactMouseEvent, useState } from 'react' +import { type ComponentType, type MouseEvent as ReactMouseEvent, useState } from 'react' import { + Chip, chipVariants, cn, DropdownMenu, @@ -11,110 +12,117 @@ import { DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, + Loader, } from '@sim/emcn' -import { File, Folder, MoreHorizontal, Pencil, Plus, SquareArrowUpRight } from '@sim/emcn/icons' +import { Folder, MoreHorizontal, Pencil, Pin, Plus, SquareArrowUpRight } from '@sim/emcn/icons' import Link from 'next/link' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' +import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders' +import { + SidebarNavChip, + type SidebarNavItemData, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-nav-chip' import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' import type { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { interleaveSiblings } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' -import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders' import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -interface FileFolderFlyoutNode extends WorkspaceFileFolderApi { - children: FileFolderFlyoutNode[] - files: WorkspaceFileRecord[] +interface CollapsedResourceFlyoutProps { + entries: FlyoutEntry[] + /** Icon for the resource rows. Folders always carry the folder glyph. */ + icon: ComponentType<{ className?: string }> + /** Resource open on the current route, so its row reads as selected. */ + currentItemId?: string + /** + * True until the lists that decide which rows EXIST have resolved once — the resources and + * their folders. Both are needed before anything renders: a resource whose folder has not + * arrived yet would show at the root and then jump into it. Pins are deliberately not + * waited on, since they only reorder rows that are already correct. + */ + isLoading?: boolean + emptyLabel: string } -type FileFlyoutEntry = - | { kind: 'folder'; id: string; name: string; folder: FileFolderFlyoutNode } - | { kind: 'file'; id: string; name: string; file: WorkspaceFileRecord } - /** - * Orders one level of the file flyout as a single list. Folders are not hoisted - * above the files beside them — the Files page sorts folders and files together, - * and a flyout that partitioned them would contradict the page it links into. + * Rail flyout body for a foldered workspace resource (Tables, Files). Every row + * is a link — the flyout is a jump list, so folders open as submenus rather than + * navigating, and an empty one has nowhere to go and is inert. */ -function fileFlyoutEntries( - folders: FileFolderFlyoutNode[], - files: WorkspaceFileRecord[] -): FileFlyoutEntry[] { - const entries: FileFlyoutEntry[] = [ - ...folders.map( - (folder): FileFlyoutEntry => ({ - kind: 'folder', - id: folder.id, - name: folder.name, - folder, - }) - ), - ...files.map( - (file): FileFlyoutEntry => ({ - kind: 'file', - id: file.id, - name: file.name, - file, - }) - ), - ] - return entries.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) +export function CollapsedResourceFlyout({ + entries, + icon, + currentItemId, + isLoading = false, + emptyLabel, +}: CollapsedResourceFlyoutProps) { + if (isLoading) { + return ( + + + Loading... + + ) + } + if (entries.length === 0) { + return {emptyLabel} + } + return } -const FILE_FLYOUT_ICON = ( -