Skip to content

Commit 02ae2b4

Browse files
authored
feat(sidebar): add Tables and Files flyouts to the collapsed rail (#6882)
* 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.
1 parent f17938c commit 02ae2b4

17 files changed

Lines changed: 1119 additions & 423 deletions

File tree

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { buildFlyoutEntries } from '@/app/workspace/[workspaceId]/components/folders/flyout-entries'
6+
7+
function folder(id: string, name: string, parentId: string | null, updatedAt: string) {
8+
return { id, name, parentId, updatedAt: new Date(updatedAt) }
9+
}
10+
11+
function item(id: string, name: string, folderId: string | null, updatedAt: string) {
12+
return { id, name, folderId, updatedAt: new Date(updatedAt) }
13+
}
14+
15+
const NONE: ReadonlySet<string> = new Set()
16+
17+
function build(
18+
folders: ReturnType<typeof folder>[],
19+
items: ReturnType<typeof item>[],
20+
pinned?: { folders?: ReadonlySet<string>; items?: ReadonlySet<string> }
21+
) {
22+
return buildFlyoutEntries({
23+
folders,
24+
items,
25+
pinnedFolderIds: pinned?.folders ?? NONE,
26+
pinnedItemIds: pinned?.items ?? NONE,
27+
hrefForItem: (row) => `/x/${row.id}`,
28+
})
29+
}
30+
31+
describe('buildFlyoutEntries', () => {
32+
it('orders folders and items together, most-recently-updated first', () => {
33+
const entries = build(
34+
[
35+
folder('f1', 'Older folder', null, '2026-01-01'),
36+
folder('f2', 'Newest', null, '2026-03-01'),
37+
],
38+
[item('i1', 'Middle', null, '2026-02-01')]
39+
)
40+
41+
expect(entries.map((entry) => entry.id)).toEqual(['f2', 'i1', 'f1'])
42+
})
43+
44+
it('floats pinned rows above newer unpinned ones, matching the list pages', () => {
45+
const entries = build(
46+
[folder('f1', 'Folder', null, '2026-03-01')],
47+
[item('i1', 'Pinned', null, '2026-01-01'), item('i2', 'Newest', null, '2026-04-01')],
48+
{ items: new Set(['i1']) }
49+
)
50+
51+
expect(entries.map((entry) => entry.id)).toEqual(['i1', 'i2', 'f1'])
52+
})
53+
54+
it('breaks ties on name', () => {
55+
const entries = build(
56+
[],
57+
[
58+
item('b', 'Beta', null, '2026-01-01'),
59+
item('c', 'Alpha', null, '2026-01-01'),
60+
item('a', 'Gamma', null, '2026-01-01'),
61+
]
62+
)
63+
64+
expect(entries.map((entry) => entry.id)).toEqual(['c', 'b', 'a'])
65+
})
66+
67+
it('nests items under their folder and links each one', () => {
68+
const entries = build(
69+
[folder('f1', 'Reports', null, '2026-01-01'), folder('f2', 'Q1', 'f1', '2026-01-02')],
70+
[item('i1', 'Revenue', 'f2', '2026-01-03')]
71+
)
72+
73+
expect(entries).toEqual([
74+
{
75+
kind: 'folder',
76+
id: 'f1',
77+
name: 'Reports',
78+
pinned: false,
79+
children: [
80+
{
81+
kind: 'folder',
82+
id: 'f2',
83+
name: 'Q1',
84+
pinned: false,
85+
children: [{ kind: 'item', id: 'i1', name: 'Revenue', pinned: false, href: '/x/i1' }],
86+
},
87+
],
88+
},
89+
])
90+
})
91+
92+
it('hoists a folder and an item whose parent folder is gone to the root', () => {
93+
const entries = build(
94+
[folder('f1', 'Orphan', 'archived-folder', '2026-01-02')],
95+
[item('i1', 'Loose', 'archived-folder', '2026-01-01')]
96+
)
97+
98+
expect(entries.map((entry) => entry.id)).toEqual(['f1', 'i1'])
99+
expect(entries[0]).toMatchObject({ kind: 'folder', children: [] })
100+
})
101+
102+
it('drops folders reachable only through a parent cycle instead of descending it', () => {
103+
const entries = build(
104+
[
105+
folder('a', 'A', 'b', '2026-01-01'),
106+
folder('b', 'B', 'a', '2026-01-01'),
107+
folder('root', 'Root', null, '2026-01-01'),
108+
],
109+
[]
110+
)
111+
112+
expect(entries.map((entry) => entry.id)).toEqual(['root'])
113+
})
114+
115+
it('accepts serialized date strings and sorts undated rows last', () => {
116+
const entries = buildFlyoutEntries({
117+
folders: [],
118+
items: [
119+
{ id: 'i1', name: 'Undated', folderId: null, updatedAt: 'not-a-date' },
120+
{ id: 'i2', name: 'Dated', folderId: null, updatedAt: '2026-01-01T00:00:00.000Z' },
121+
],
122+
pinnedFolderIds: NONE,
123+
pinnedItemIds: NONE,
124+
hrefForItem: (row) => `/x/${row.id}`,
125+
})
126+
127+
expect(entries.map((entry) => entry.id)).toEqual(['i2', 'i1'])
128+
})
129+
130+
it('treats a missing folderId as the root', () => {
131+
const entries = buildFlyoutEntries({
132+
folders: [],
133+
items: [{ id: 'i1', name: 'Rootless', updatedAt: new Date('2026-01-01') }],
134+
pinnedFolderIds: NONE,
135+
pinnedItemIds: NONE,
136+
hrefForItem: (row) => `/x/${row.id}`,
137+
})
138+
139+
expect(entries).toEqual([
140+
{ kind: 'item', id: 'i1', name: 'Rootless', pinned: false, href: '/x/i1' },
141+
])
142+
})
143+
144+
it('keeps each nesting level ordered independently, not just the root', () => {
145+
const entries = build(
146+
[folder('f1', 'Root folder', null, '2026-05-01')],
147+
[
148+
item('deep-old', 'Deep old', 'f1', '2026-01-01'),
149+
item('deep-new', 'Deep new', 'f1', '2026-04-01'),
150+
item('root-mid', 'Root mid', null, '2026-03-01'),
151+
]
152+
)
153+
154+
expect(entries.map((entry) => entry.id)).toEqual(['f1', 'root-mid'])
155+
const nested = entries[0]
156+
expect(nested.kind).toBe('folder')
157+
if (nested.kind !== 'folder') throw new Error('expected a folder')
158+
expect(nested.children.map((child) => child.id)).toEqual(['deep-new', 'deep-old'])
159+
})
160+
161+
it('preserves the full depth of the folder chain', () => {
162+
const entries = build(
163+
[
164+
folder('a', 'A', null, '2026-01-01'),
165+
folder('b', 'B', 'a', '2026-01-01'),
166+
folder('c', 'C', 'b', '2026-01-01'),
167+
],
168+
[item('leaf', 'Leaf', 'c', '2026-01-01')]
169+
)
170+
171+
const depth = (rows: ReturnType<typeof build>): number => {
172+
const nested = rows.find((row) => row.kind === 'folder')
173+
return nested && nested.kind === 'folder' ? 1 + depth(nested.children) : 0
174+
}
175+
expect(depth(entries)).toBe(3)
176+
})
177+
178+
it('keeps an empty folder in the tree rather than dropping it', () => {
179+
const entries = build(
180+
[folder('empty', 'Nothing here', null, '2026-01-01')],
181+
[item('i1', 'Loose', null, '2026-01-02')]
182+
)
183+
184+
expect(entries.map((entry) => entry.id)).toEqual(['i1', 'empty'])
185+
expect(entries[1]).toMatchObject({ kind: 'folder', children: [] })
186+
})
187+
188+
it('marks pinned folders and pinned resources so the ordering is legible', () => {
189+
const entries = build(
190+
[folder('f1', 'Folder', null, '2026-01-01')],
191+
[item('i1', 'Table', null, '2026-01-02')],
192+
{ folders: new Set(['f1']), items: new Set(['i1']) }
193+
)
194+
195+
expect(entries.map((entry) => entry.pinned)).toEqual([true, true])
196+
})
197+
})
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import {
2+
type SortableResource,
3+
sortResources,
4+
} from '@/app/workspace/[workspaceId]/components/folders/resource-sort'
5+
6+
/** A folder row a resource flyout can render, from any foldered workspace surface. */
7+
interface FlyoutFolderSource {
8+
id: string
9+
name: string
10+
parentId: string | null
11+
updatedAt: Date | string
12+
}
13+
14+
/** A resource row a flyout can render, from any foldered workspace surface. */
15+
interface FlyoutItemSource {
16+
id: string
17+
name: string
18+
folderId?: string | null
19+
updatedAt: Date | string
20+
}
21+
22+
/** One row of a resource flyout: a folder that recurses, or a linked resource. */
23+
export type FlyoutEntry =
24+
| { kind: 'folder'; id: string; name: string; pinned: boolean; children: FlyoutEntry[] }
25+
| { kind: 'item'; id: string; name: string; pinned: boolean; href: string }
26+
27+
export interface BuildFlyoutEntriesParams<Item extends FlyoutItemSource> {
28+
folders: FlyoutFolderSource[]
29+
items: Item[]
30+
pinnedFolderIds: ReadonlySet<string>
31+
pinnedItemIds: ReadonlySet<string>
32+
hrefForItem: (item: Item) => string
33+
}
34+
35+
function flyoutSortTime(value: Date | string): number {
36+
const time = value instanceof Date ? value.getTime() : Date.parse(value)
37+
return Number.isNaN(time) ? 0 : time
38+
}
39+
40+
/**
41+
* Builds the ordered row tree a foldered resource's flyout renders.
42+
*
43+
* Each level is sorted by the shared {@link sortResources}, on the most-recently-updated
44+
* key its list page defaults to — so pinned rows float, folders interleave with the
45+
* resources beside them, and the flyout keeps reading in the same order as the page it
46+
* links into rather than carrying a second copy of that rule. `pinned` rides along on each
47+
* row because that ordering reads as arbitrary without the indicator the rows render from
48+
* it — the same pairing `Resource`'s own cells make.
49+
*
50+
* A folder whose parent no longer exists, and a resource whose `folderId` names no live
51+
* folder, surface at the root — the same fallback the list pages apply when a folder is
52+
* archived out from under its contents, so neither goes unreachable. A folder only
53+
* reachable through a parent cycle is dropped, as it is by the sidebar's folder tree: the
54+
* client folder cache is written optimistically, so a cycle is reachable there even though
55+
* the server rejects one, and descending it would hang the tab.
56+
*/
57+
export function buildFlyoutEntries<Item extends FlyoutItemSource>({
58+
folders,
59+
items,
60+
pinnedFolderIds,
61+
pinnedItemIds,
62+
hrefForItem,
63+
}: BuildFlyoutEntriesParams<Item>): FlyoutEntry[] {
64+
const folderIds = new Set(folders.map((folder) => folder.id))
65+
66+
const foldersByParent = new Map<string | null, FlyoutFolderSource[]>()
67+
for (const folder of folders) {
68+
const parentId = folder.parentId && folderIds.has(folder.parentId) ? folder.parentId : null
69+
const siblings = foldersByParent.get(parentId)
70+
if (siblings) siblings.push(folder)
71+
else foldersByParent.set(parentId, [folder])
72+
}
73+
74+
const itemsByFolder = new Map<string | null, Item[]>()
75+
for (const item of items) {
76+
const folderId = item.folderId && folderIds.has(item.folderId) ? item.folderId : null
77+
const siblings = itemsByFolder.get(folderId)
78+
if (siblings) siblings.push(item)
79+
else itemsByFolder.set(folderId, [item])
80+
}
81+
82+
const buildLevel = (parentId: string | null): FlyoutEntry[] => {
83+
const rows: SortableResource<FlyoutEntry>[] = []
84+
for (const folder of foldersByParent.get(parentId) ?? []) {
85+
const pinned = pinnedFolderIds.has(folder.id)
86+
rows.push({
87+
item: {
88+
kind: 'folder',
89+
id: folder.id,
90+
name: folder.name,
91+
pinned,
92+
children: buildLevel(folder.id),
93+
},
94+
pinned,
95+
name: folder.name,
96+
key: flyoutSortTime(folder.updatedAt),
97+
})
98+
}
99+
for (const item of itemsByFolder.get(parentId) ?? []) {
100+
const pinned = pinnedItemIds.has(item.id)
101+
rows.push({
102+
item: { kind: 'item', id: item.id, name: item.name, pinned, href: hrefForItem(item) },
103+
pinned,
104+
name: item.name,
105+
key: flyoutSortTime(item.updatedAt),
106+
})
107+
}
108+
return sortResources(rows, 'desc').map((row) => row.item)
109+
}
110+
111+
return buildLevel(null)
112+
}

apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ElementType } from 'react'
1+
import type { ComponentType } from 'react'
22
import { Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons'
33
import type { FolderResourceType } from '@/lib/api/contracts/folders'
44
import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders/search-params'
@@ -17,7 +17,7 @@ export interface FolderedResourceHeaderMeta {
1717
/** Root crumb label, and the page title at the workspace root. */
1818
rootLabel: string
1919
/** Icon on the root crumb, which is also what opens the header's "Path" popover. */
20-
rootIcon: ElementType
20+
rootIcon: ComponentType<{ className?: string }>
2121
/** Path segment of the list page under `/workspace/[workspaceId]/`. */
2222
listSegment: string
2323
}

apps/sim/app/workspace/[workspaceId]/components/folders/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export { readRowDragPayload, writeRowDragPayload } from './drag-payload'
2+
export type { BuildFlyoutEntriesParams, FlyoutEntry } from './flyout-entries'
3+
export { buildFlyoutEntries } from './flyout-entries'
24
export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs'
35
export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs'
46
export { FolderContextMenu } from './folder-context-menu'

0 commit comments

Comments
 (0)