diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index 1c45eae5416..25034257a33 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -179,7 +179,7 @@ const { sort, dir, activeSort, onSort, onClear } = useUrlSort(thingsSortParams, Two modes, chosen by whether you pass a default: - **Defaulted (the common case)** — pass the list's existing default sort; it must match exactly. A clean URL means the default ordering; explicitly selecting the default collapses back to a clean URL (`clearOnDefault`), and "clear sort" writes the defaults back. `useUrlSort` derives `activeSort: null` for the default state. -- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. files: with no sort, files order by updated/desc but folders by name/asc). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s). +- **Nullable** — omit the default when "no active sort" is behaviorally distinct from explicitly sorting by the fallback column (e.g. document chunks: with no sort the query omits `sortBy` entirely and the server's own order applies). The params carry no defaults, explicit selections always persist in the URL, and "clear sort" strips both params (`useUrlSort` writes `null`s). Sort params live alongside — not inside — the feature's grouped filter parser map (one definition per param; `useUrlSort` owns its own `useQueryStates`, and nuqs keeps hooks on the same keys in sync). Both params carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). Free-form user-defined columns (e.g. `tables/[tableId]`) can't use `parseAsStringLiteral` and stay hand-rolled with `parseAsString` — reuse the shared `SORT_DIRECTIONS` there. diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index acc8e52c454..784f44af960 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -15,6 +15,8 @@ export { renderMoveOption, renderMoveOptions, } from './move-options' +export type { SortableResource } from './resource-sort' +export { sortResources } from './resource-sort' export { folderNavParsers, folderNavUrlKeys } from './search-params' export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation' export { useFolderNavigation } from './use-folder-navigation' diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/resource-sort.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/resource-sort.test.ts new file mode 100644 index 00000000000..2670fe5e5c0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/resource-sort.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + type SortableResource, + sortResources, +} from '@/app/workspace/[workspaceId]/components/folders/resource-sort' + +type Kind = 'folder' | 'item' + +function entry( + name: string, + kind: Kind, + key: string | number | null, + pinned = false +): SortableResource<{ name: string; kind: Kind }> { + return { item: { name, kind }, pinned, name, key } +} + +const names = (entries: SortableResource<{ name: string; kind: Kind }>[]) => + entries.map((e) => e.item.name) + +describe('sortResources', () => { + it('interleaves folders and items on the sort key instead of hoisting folders', () => { + const sorted = sortResources( + [ + entry('b-folder', 'folder', 'b-folder'), + entry('a-item', 'item', 'a-item'), + entry('c-item', 'item', 'c-item'), + ], + 'asc' + ) + + expect(names(sorted)).toEqual(['a-item', 'b-folder', 'c-item']) + }) + + it('floats a pinned item above every unpinned folder', () => { + const sorted = sortResources( + [ + entry('a-folder', 'folder', 'a-folder'), + entry('b-folder', 'folder', 'b-folder'), + entry('z-item', 'item', 'z-item', true), + ], + 'asc' + ) + + expect(names(sorted)).toEqual(['z-item', 'a-folder', 'b-folder']) + }) + + it('keeps pinned rows on top when the direction flips', () => { + const sorted = sortResources( + [ + entry('a-folder', 'folder', 3), + entry('b-item', 'item', 2), + entry('c-item', 'item', 1, true), + ], + 'desc' + ) + + expect(names(sorted)).toEqual(['c-item', 'a-folder', 'b-item']) + }) + + it('orders pinned rows among themselves by the active key', () => { + const sorted = sortResources( + [ + entry('a-item', 'item', 1, true), + entry('b-folder', 'folder', 3, true), + entry('c-item', 'item', 2, true), + ], + 'desc' + ) + + expect(names(sorted)).toEqual(['b-folder', 'c-item', 'a-item']) + }) + + it('sorts rows with no value for the column last in both directions', () => { + const rows = [ + entry('folder-a', 'folder', null), + entry('item-big', 'item', 10), + entry('item-small', 'item', 1), + ] + + expect(names(sortResources([...rows], 'asc'))).toEqual(['item-small', 'item-big', 'folder-a']) + expect(names(sortResources([...rows], 'desc'))).toEqual(['item-big', 'item-small', 'folder-a']) + }) + + it('still floats a pinned row that has no value for the column', () => { + const sorted = sortResources( + [entry('item-a', 'item', 5), entry('folder-z', 'folder', null, true)], + 'asc' + ) + + expect(names(sorted)).toEqual(['folder-z', 'item-a']) + }) + + it('sorts a row whose cell renders empty last, not first', () => { + // An owner id that resolves to no workspace member renders an empty cell, so its key is + // `null` — passing `''` instead would float those rows to the top of an ascending sort. + const rows = [entry('unknown-owner', 'item', null), entry('ada', 'item', 'Ada')] + + expect(names(sortResources([...rows], 'asc'))).toEqual(['ada', 'unknown-owner']) + expect(names(sortResources([...rows], 'desc'))).toEqual(['ada', 'unknown-owner']) + }) + + it('breaks ties by name ascending regardless of direction', () => { + const rows = [ + entry('charlie', 'item', 1), + entry('alpha', 'folder', 1), + entry('bravo', 'item', 1), + ] + + expect(names(sortResources([...rows], 'asc'))).toEqual(['alpha', 'bravo', 'charlie']) + expect(names(sortResources([...rows], 'desc'))).toEqual(['alpha', 'bravo', 'charlie']) + }) + + it('breaks ties by name among rows that all lack a value', () => { + const sorted = sortResources( + [entry('zeta', 'folder', null), entry('alpha', 'folder', null)], + 'desc' + ) + + expect(names(sorted)).toEqual(['alpha', 'zeta']) + }) + + it('compares string keys case-insensitively via localeCompare', () => { + const sorted = sortResources( + [entry('Beta', 'item', 'Beta'), entry('alpha', 'folder', 'alpha')], + 'asc' + ) + + expect(names(sorted)).toEqual(['alpha', 'Beta']) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/resource-sort.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/resource-sort.ts new file mode 100644 index 00000000000..c465c9b28fb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/resource-sort.ts @@ -0,0 +1,54 @@ +import type { SortDirection } from '@/lib/url-state' + +/** + * One row of a foldered list, decorated with everything the comparator needs so the sort + * itself stays O(N log N) on precomputed values rather than re-deriving keys per comparison. + */ +export interface SortableResource { + /** The payload handed back in sorted order. */ + item: T + /** Pinned rows float to the top of every column and direction. */ + pinned: boolean + /** Display name — the final, direction-independent tiebreaker. */ + name: string + /** + * Value for the active sort column, or `null` when the column does not apply to this row + * (a folder has no row count, token count, or connector list). Null keys sort last in both + * directions, the same "nulls last" rule the log list applies server-side. + */ + key: string | number | null +} + +/** + * Orders folders and the resources they contain as ONE list. + * + * Folders are not hoisted above their siblings: a folder outranking every file meant a pinned + * file could never reach the top of the list, since pinning only reordered within each + * partition. Precedence is pinned → sort key → name, so pinning is the only thing that jumps + * a row, and it does so regardless of which column is sorted or which way. + * + * Neither the pinned bit nor the name tiebreaker is inverted by `desc` — pinning is a + * user-declared priority rather than another sort key, and a stable A→Z tiebreak keeps rows + * that tie on the active column (equal timestamps, a whole column of `null` folder keys) in + * one predictable order instead of the arbitrary one their source arrays happened to have. + */ +export function sortResources( + entries: SortableResource[], + direction: SortDirection +): SortableResource[] { + return entries.sort((a, b) => { + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 + + if (a.key === null || b.key === null) { + if (a.key !== b.key) return a.key === null ? 1 : -1 + } else { + const cmp = + typeof a.key === 'number' && typeof b.key === 'number' + ? a.key - b.key + : String(a.key).localeCompare(String(b.key)) + if (cmp !== 0) return direction === 'asc' ? cmp : -cmp + } + + return a.name.localeCompare(b.name) + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 97899c73e10..736ec48089e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -61,10 +61,14 @@ import { Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import type { + MoveOptionNode, + SortableResource, +} from '@/app/workspace/[workspaceId]/components/folders' import { parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, + sortResources, } from '@/app/workspace/[workspaceId]/components/folders' import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' @@ -124,8 +128,18 @@ type FileResourceItem = | { kind: 'file'; id: string; file: WorkspaceFileRecord } | { kind: 'folder'; id: string; folder: WorkspaceFileFolderApi } +/** One row of the merged folder+file list, before it becomes a `ResourceRow`. */ +type FileListEntry = + | { kind: 'folder'; folder: WorkspaceFileFolderApi } + | { kind: 'file'; file: WorkspaceFileRecord } + const logger = createLogger('Files') +const FOLDER_ICON = + +/** Folders' value in the `type` column — also their sort key when that column is active. */ +const FOLDER_TYPE_LABEL = 'Folder' as const + /** * Debounce window for `search` URL writes and filtering; the input itself stays * instant. Intentionally shorter than the shared `SEARCH_DEBOUNCE_MS` (300). @@ -298,13 +312,13 @@ export function Files() { ) const debouncedSearchTerm = useDebounce(urlSearchTerm, FILES_SEARCH_DEBOUNCE_MS) - /** - * `sort`/`dir` are nullable in the URL because "no active sort" is distinct - * from an explicit updated/desc selection: with no sort, files fall back to - * updated/desc but folders to name/asc, while an explicit sort orders both - * sections by the chosen column. - */ - const { activeSort, onSort, onClear } = useUrlSort(filesSortParams, filesFilterUrlKeys) + const { + sort: sortColumn, + dir: sortDirection, + activeSort, + onSort, + onClear, + } = useUrlSort(filesSortParams, filesFilterUrlKeys) const setTypeFilter = useCallback( (next: string[]) => setFileFilters({ type: next }), @@ -463,34 +477,10 @@ export function Files() { const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) const needle = debouncedSearchTerm.trim().toLowerCase() - const searched = needle + return needle ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) : siblings - const col = activeSort?.column ?? 'name' - const dir = activeSort?.direction ?? 'asc' - // Decorate-sort: compute each key + pinned flag once (O(N)) rather than parsing dates per comparison. - const decorated = searched.map((folder) => ({ - folder, - pinned: pinnedFolderIds.has(folder.id), - key: - col === 'updated' - ? new Date(folder.updatedAt).getTime() - : col === 'created' - ? new Date(folder.createdAt).getTime() - : folder.name, - })) - decorated.sort((a, b) => { - // Pinned folders float to the top of every sort/direction — pinning is a - // user-declared priority, not another sort key to be inverted by `desc`. - if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 - const cmp = - typeof a.key === 'number' && typeof b.key === 'number' - ? a.key - b.key - : String(a.key).localeCompare(String(b.key)) - return dir === 'asc' ? cmp : -cmp - }) - return decorated.map((d) => d.folder) - }, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds]) + }, [folders, currentFolderId, debouncedSearchTerm]) const filteredFiles = useMemo(() => { const needle = debouncedSearchTerm.trim().toLowerCase() @@ -525,102 +515,129 @@ export function Files() { result = result.filter((f) => uploadedByFilter.includes(f.uploadedBy)) } - const col = activeSort?.column ?? 'updated' - const dir = activeSort?.direction ?? 'desc' - // Decorate-sort: compute each row's sort key + pinned flag ONCE (O(N)), then compare precomputed - // keys — the comparator ran per-comparison work (Date parsing, `formatFileType`, member lookups) at - // O(N log N). Stable sort + pinned-primary ordering are preserved. - const decorated = result.map((f) => ({ - f, - pinned: pinnedFileIds.has(f.id), - key: - col === 'size' - ? f.size - : col === 'type' - ? formatFileType(f.type, f.name) - : col === 'created' - ? new Date(f.uploadedAt).getTime() - : col === 'updated' - ? new Date(f.updatedAt).getTime() - : col === 'owner' - ? (membersById.get(f.uploadedBy)?.name ?? '') - : f.name, - })) - decorated.sort((a, b) => { - // Pinned files float to the top of every sort/direction — pinning is a - // user-declared priority, not another sort key to be inverted by `desc`. - if (a.pinned !== b.pinned) return a.pinned ? -1 : 1 - const cmp = - typeof a.key === 'number' && typeof b.key === 'number' - ? a.key - b.key - : String(a.key).localeCompare(String(b.key)) - return dir === 'asc' ? cmp : -cmp - }) - return decorated.map((d) => d.f) + return result + }, [files, currentFolderId, debouncedSearchTerm, typeFilter, sizeFilter, uploadedByFilter]) + + /** + * Folders and files sort as ONE list — a folder never outranks a file it ties with, so a + * pinned file reaches the top of the list rather than the top of the file section. + * + * Decorate-sort: each row's key + pinned flag is computed ONCE (O(N)) so the comparator + * never re-runs Date parsing, `formatFileType`, or member lookups per comparison. Every + * Files column has a folder equivalent (folders carry a size roll-up and sort as type + * "Folder"), so no entry needs a null key here. + */ + const sortedEntries = useMemo(() => { + const entries: SortableResource[] = [] + + for (const folder of visibleFolders) { + entries.push({ + item: { kind: 'folder', folder }, + pinned: pinnedFolderIds.has(folder.id), + name: folder.name, + key: + sortColumn === 'size' + ? (folderSizeMap.get(folder.id) ?? 0) + : sortColumn === 'type' + ? FOLDER_TYPE_LABEL + : sortColumn === 'created' + ? new Date(folder.createdAt).getTime() + : sortColumn === 'updated' + ? new Date(folder.updatedAt).getTime() + : sortColumn === 'owner' + ? (membersById.get(folder.userId)?.name ?? null) + : folder.name, + }) + } + + for (const file of filteredFiles) { + entries.push({ + item: { kind: 'file', file }, + pinned: pinnedFileIds.has(file.id), + name: file.name, + key: + sortColumn === 'size' + ? file.size + : sortColumn === 'type' + ? formatFileType(file.type, file.name) + : sortColumn === 'created' + ? new Date(file.uploadedAt).getTime() + : sortColumn === 'updated' + ? new Date(file.updatedAt).getTime() + : sortColumn === 'owner' + ? (membersById.get(file.uploadedBy)?.name ?? null) + : file.name, + }) + } + + return sortResources(entries, sortDirection) }, [ - files, - currentFolderId, - debouncedSearchTerm, - typeFilter, - sizeFilter, - uploadedByFilter, - activeSort, + visibleFolders, + filteredFiles, + sortColumn, + sortDirection, membersById, + folderSizeMap, + pinnedFolderIds, pinnedFileIds, ]) - const baseRows: ResourceRow[] = useMemo(() => { - const folderRows = visibleFolders.map((folder) => ({ - id: folderRowId(folder.id), - cells: { - name: { - icon: , - label: folder.name, - pinned: pinnedFolderIds.has(folder.id), - }, - size: { - label: - (folderSizeMap.get(folder.id) ?? 0) > 0 - ? formatFileSize(folderSizeMap.get(folder.id)!, { includeBytes: true }) - : EMPTY_CELL_PLACEHOLDER, - }, - type: { - icon: , - label: 'Folder', - }, - created: timeCell(folder.createdAt), - owner: ownerCell(folder.userId, membersById), - updated: timeCell(folder.updatedAt), - }, - })) + const baseRows: ResourceRow[] = useMemo( + () => + sortedEntries.map(({ item, pinned }): ResourceRow => { + if (item.kind === 'folder') { + const { folder } = item + const totalSize = folderSizeMap.get(folder.id) ?? 0 + return { + id: folderRowId(folder.id), + cells: { + name: { + icon: FOLDER_ICON, + label: folder.name, + pinned, + }, + size: { + label: + totalSize > 0 + ? formatFileSize(totalSize, { includeBytes: true }) + : EMPTY_CELL_PLACEHOLDER, + }, + type: { + icon: FOLDER_ICON, + label: FOLDER_TYPE_LABEL, + }, + created: timeCell(folder.createdAt), + owner: ownerCell(folder.userId, membersById), + updated: timeCell(folder.updatedAt), + }, + } + } - const fileRows = filteredFiles.map((file) => { - const Icon = getDocumentIcon(file.type || '', file.name) - const row: ResourceRow = { - id: fileRowId(file.id), - cells: { - name: { - icon: , - label: file.name, - pinned: pinnedFileIds.has(file.id), - }, - size: { - label: formatFileSize(file.size, { includeBytes: true }), - }, - type: { - icon: , - label: formatFileType(file.type, file.name), + const { file } = item + const Icon = getDocumentIcon(file.type || '', file.name) + return { + id: fileRowId(file.id), + cells: { + name: { + icon: , + label: file.name, + pinned, + }, + size: { + label: formatFileSize(file.size, { includeBytes: true }), + }, + type: { + icon: , + label: formatFileType(file.type, file.name), + }, + created: timeCell(file.uploadedAt), + owner: ownerCell(file.uploadedBy, membersById), + updated: timeCell(file.updatedAt), }, - created: timeCell(file.uploadedAt), - owner: ownerCell(file.uploadedBy, membersById), - updated: timeCell(file.updatedAt), - }, - } - return row - }) - - return [...folderRows, ...fileRows] - }, [visibleFolders, filteredFiles, membersById, folderSizeMap, pinnedFolderIds, pinnedFileIds]) + } + }), + [sortedEntries, membersById, folderSizeMap] + ) const rows: ResourceRow[] = useMemo(() => { if (!listRename.editingId) return baseRows diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts index 0fb0de96156..5d94a6f0d7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -2,6 +2,7 @@ import type { QueryClient } from '@tanstack/react-query' import type { WorkspaceFileFolderApi } from '@/lib/api/contracts/workspace-file-folders' import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { WORKSPACE_FILE_FOLDERS_STALE_TIME, workspaceFileFolderKeys, @@ -12,7 +13,9 @@ import { } from '@/hooks/queries/workspace-files' /** - * Prefetches the Files browser's two lists — workspace files and file folders — + * Prefetches everything the Files browser needs to paint a complete, correctly-ordered + * first frame: workspace files, file folders, and (via {@link prefetchResourceListChrome}) + * the pinned ids that drive row order plus the members behind the Owner column — * under the same query keys their client hooks (`useWorkspaceFiles`, * `useWorkspaceFileFolders`) use (scope `active`), so the browser paints * populated on first render. @@ -45,5 +48,6 @@ export async function prefetchFilesBrowser( }, staleTime: WORKSPACE_FILE_FOLDERS_STALE_TIME, }), + prefetchResourceListChrome(queryClient, workspaceId, 'file'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts index 8ef1d7c3803..6bb0f23006e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/search-params.ts @@ -72,15 +72,15 @@ export const filesFilterParsers = { } as const /** - * `sort` / `dir` follow the shared sort convention (two scalar params) in - * nullable mode (no `defaultSort`) because "no active sort" is behaviorally - * distinct from explicitly sorting by the fallback column: with no sort, files - * order by updated/desc but folders by name/asc, while an explicit updated/desc - * sorts both sections by updatedAt. Collapsing the explicit selection into a - * clean URL would make that folder ordering unreachable. Clearing the sort - * writes `null`, which strips both params. + * `sort` / `dir` follow the shared sort convention (two scalar params). The + * default (most-recently-updated first) matches the list's default ordering, so + * a clean URL means the default sort. Folders and files sort as one list, so + * there is no folder-only ordering that a clean URL would have to encode. */ -export const filesSortParams = createSortParams(FILE_SORT_COLUMNS) +export const filesSortParams = createSortParams(FILE_SORT_COLUMNS, { + column: 'updated', + direction: 'desc', +}) /** Filter/search/sort view-state: clean URLs, no back-stack churn. */ export const filesFilterUrlKeys = { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx index 78febe23caf..d185e7fc96c 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/add-resource-dropdown.tsx @@ -314,15 +314,13 @@ export function useAvailableResources( ]) /** - * Sorted by name to match how the Tables and Knowledge pages order folders — - * the list endpoint makes no ordering guarantee, and these folders carry no - * user-defined ordering the way workflow folders do. + * Left in source order: `buildResourceFolderTree` orders each level by name, + * interleaved with the items, matching the Tables and Knowledge pages. These + * folders carry no user-defined ordering the way workflow folders do. */ const structureFolders = useMemo(() => { const toFolderItems = (source: typeof tableFolders): AvailableItem[] => - (source ?? []) - .map((f) => ({ id: f.id, name: f.name, parentId: f.parentId ?? null })) - .sort((a, b) => a.name.localeCompare(b.name)) + (source ?? []).map((f) => ({ id: f.id, name: f.name, parentId: f.parentId ?? null })) return { table: toFolderItems(tableFolders), knowledgebase: toFolderItems(knowledgeBaseFolders), diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.test.ts index c78157db8bb..9984214b4ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.test.ts @@ -62,6 +62,33 @@ describe('buildResourceFolderTree', () => { expect(shape(tree)).toEqual(['full[kept]']) }) + it('interleaves folders and items by name by default', () => { + const tree = buildResourceFolderTree( + [item('apple'), item('cherry')], + [folder('banana'), folder('date')] + ) + expect(shape(tree)).toEqual(['apple', 'banana[]', 'cherry', 'date[]']) + }) + + it('orders nested levels by name too', () => { + const tree = buildResourceFolderTree( + [item('zebra', 'parent'), item('alpha', 'parent')], + [folder('parent'), folder('middle', 'parent')] + ) + expect(shape(tree)).toEqual(['parent[alpha,middle[],zebra]']) + }) + + it('breaks name ties by id so ordering stays stable', () => { + const tree = buildResourceFolderTree( + [ + { id: 'b', name: 'same', folderId: null }, + { id: 'a', name: 'same', folderId: null }, + ], + [] + ) + expect(shape(tree)).toEqual(['a', 'b']) + }) + it('interleaves folders and items by sortOrder when orderBySortOrder is set', () => { const tree = buildResourceFolderTree( [item('itemA', null, 1), item('itemC', null, 3)], diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.ts index ac9bd787dac..ad2a58ca289 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/add-resource-dropdown/resource-folder-tree.ts @@ -6,9 +6,10 @@ export type ResourceTreeNode = export interface BuildResourceFolderTreeOptions { /** - * Interleave folders and items at each level by their `sortOrder`, mirroring - * the workflow sidebar's manual ordering. Otherwise folders come first, then - * items, each in source order. + * Order each level by `sortOrder`, mirroring the workflow sidebar's manual + * ordering. Otherwise levels order by name, matching the resource pages. + * Either way folders and items interleave — a folder never outranks the item + * it sorts next to. */ orderBySortOrder?: boolean /** @@ -25,6 +26,10 @@ export interface BuildResourceFolderTreeOptions { * bases); the families differ only in the options above and in whether the * caller renders folders as selectable. * + * Folders are not hoisted above the items they sit beside — the resource pages + * sort folders and items as one list, and a browse menu that partitions them + * would order the same resources differently from the page they came from. + * * Items and folders whose parent does not resolve to a known folder surface at * the root rather than vanishing, matching how the resource pages heal orphans. * Folders forming a parent cycle resolve to no root-reachable parent and are @@ -60,13 +65,16 @@ export function buildResourceFolderTree( const sortOrderOf = (entry: AvailableItem) => (entry.sortOrder as number | undefined) ?? 0 + const compare = options?.orderBySortOrder + ? (a: AvailableItem, b: AvailableItem) => sortOrderOf(a) - sortOrderOf(b) + : (a: AvailableItem, b: AvailableItem) => a.name.localeCompare(b.name) + const buildLevel = (parentId: string | null): ResourceTreeNode[] => { const childFolders = foldersByParent.get(parentId) ?? [] const childItems = itemsByFolder.get(parentId) ?? [] - // Folders first, then items — the default order. `sources[i]` is the record - // `nodes[i]` was built from; the sort below permutes by index against that - // pairing. + // `sources[i]` is the record `nodes[i]` was built from; the sort below + // permutes by index against that pairing. const sources: AvailableItem[] = [] const nodes: ResourceTreeNode[] = [] @@ -81,12 +89,10 @@ export function buildResourceFolderTree( nodes.push({ kind: 'item', id: item.id, item }) } - if (!options?.orderBySortOrder) return nodes - return nodes .map((_, index) => index) .sort((a, b) => { - const delta = sortOrderOf(sources[a]) - sortOrderOf(sources[b]) + const delta = compare(sources[a], sources[b]) return delta !== 0 ? delta : sources[a].id.localeCompare(sources[b].id) }) .map((index) => nodes[index]) diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx deleted file mode 100644 index b16a2d3ef27..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/base-card.tsx +++ /dev/null @@ -1,308 +0,0 @@ -'use client' - -import { useCallback, useMemo, useState } from 'react' -import { Badge, DocumentAttachment, Tooltip } from '@sim/emcn' -import { formatAbsoluteDate, formatRelativeTime } from '@sim/utils/formatting' -import { useParams, useRouter } from 'next/navigation' -import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' -import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' -import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' -import type { ConnectorMeta } from '@/connectors/types' -import { DeleteKnowledgeBaseModal } from '../delete-knowledge-base-modal/delete-knowledge-base-modal' -import { EditKnowledgeBaseModal } from '../edit-knowledge-base-modal/edit-knowledge-base-modal' -import { KnowledgeBaseContextMenu } from '../knowledge-base-context-menu/knowledge-base-context-menu' - -interface BaseCardProps { - id?: string - title: string - docCount: number - description: string - createdAt?: string - updatedAt?: string - connectorTypes?: string[] - chunkingConfig?: { maxSize: number; minSize: number; overlap: number } - onUpdate?: (id: string, name: string, description: string) => Promise - onDelete?: (id: string) => Promise -} - -const EMPTY_CONNECTOR_TYPES: string[] = [] - -/** - * Skeleton placeholder for a knowledge base card - */ -export function BaseCardSkeleton() { - return ( -
-
-
-
-
- -
-
-
-
-
-
-
-
- -
- -
-
-
-
-
-
- ) -} - -/** - * Renders multiple knowledge base card skeletons as a fragment - */ -export function BaseCardSkeletonGrid({ count = 8 }: { count?: number }) { - return ( - <> - {Array.from({ length: count }).map((_, i) => ( - - ))} - - ) -} - -/** - * Knowledge base card component displaying overview information - */ -export function BaseCard({ - id, - title, - docCount, - description, - updatedAt, - connectorTypes = EMPTY_CONNECTOR_TYPES, - chunkingConfig, - onUpdate, - onDelete, -}: BaseCardProps) { - const params = useParams() - const router = useRouter() - const workspaceId = params?.workspaceId as string - const userPermissions = useUserPermissionsContext() - - const { - isOpen: isContextMenuOpen, - position: contextMenuPosition, - menuRef, - handleContextMenu, - closeMenu: closeContextMenu, - } = useContextMenu() - - const [isEditModalOpen, setIsEditModalOpen] = useState(false) - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) - const [isTagsModalOpen, setIsTagsModalOpen] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) - const connectorEntries = useMemo( - () => - connectorTypes.reduce<{ type: string; config: ConnectorMeta }[]>((acc, type) => { - const config = CONNECTOR_META_REGISTRY[type] - if (config?.icon) acc.push({ type, config }) - return acc - }, []), - [connectorTypes] - ) - const visibleConnectorEntries = useMemo(() => connectorEntries.slice(0, 3), [connectorEntries]) - const hiddenConnectorLabels = useMemo( - () => connectorEntries.slice(3).map(({ type, config }) => config?.name ?? type), - [connectorEntries] - ) - const hiddenConnectorCount = hiddenConnectorLabels.length - - const searchParams = new URLSearchParams({ - kbName: title, - }) - const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}` - - const shortId = id ? `kb-${id.slice(0, 8)}` : '' - - const navigateToKnowledgeBase = useCallback( - (e: React.MouseEvent) => { - if (isContextMenuOpen) { - e.preventDefault() - return - } - router.push(href) - }, - [isContextMenuOpen, router, href] - ) - - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - router.push(href) - } - }, - [router, href] - ) - - const handleOpenInNewTab = useCallback(() => { - window.open(href, '_blank') - }, [href]) - - const handleViewTags = useCallback(() => { - setIsTagsModalOpen(true) - }, []) - - const handleEdit = useCallback(() => { - setIsEditModalOpen(true) - }, []) - - const handleDelete = useCallback(() => { - setIsDeleteModalOpen(true) - }, []) - - const handleConfirmDelete = useCallback(async () => { - if (!id || !onDelete) return - setIsDeleting(true) - try { - await onDelete(id) - setIsDeleteModalOpen(false) - } finally { - setIsDeleting(false) - } - }, [id, onDelete]) - - const handleSave = useCallback( - async (knowledgeBaseId: string, name: string, newDescription: string) => { - if (!onUpdate) return - await onUpdate(knowledgeBaseId, name, newDescription) - }, - [onUpdate] - ) - - return ( - <> -
-
-
-

- {title} -

- {shortId && {shortId}} -
- -
-
- - - {docCount} {docCount === 1 ? 'doc' : 'docs'} - - {updatedAt && ( - - - - last updated: {formatRelativeTime(updatedAt)} - - - {formatAbsoluteDate(updatedAt)} - - )} -
- -
- -
-

- {description} -

- {connectorEntries.length > 0 && ( -
- {visibleConnectorEntries.map(({ type, config }) => { - const Icon = config.icon - return ( - - -
- -
-
- {config.name} -
- ) - })} - {hiddenConnectorCount > 0 && ( - - -
- +{hiddenConnectorCount} -
-
- {hiddenConnectorLabels.join(', ')} -
- )} -
- )} -
-
-
-
- - navigator.clipboard.writeText(id) : undefined} - onEdit={handleEdit} - onDelete={handleDelete} - showOpenInNewTab={true} - showViewTags={!!id} - showEdit={!!onUpdate} - showDelete={!!onDelete} - disableEdit={!userPermissions.canEdit} - disableDelete={!userPermissions.canEdit} - /> - - {id && onUpdate && ( - - )} - - {id && onDelete && ( - setIsDeleteModalOpen(false)} - onConfirm={handleConfirmDelete} - isDeleting={isDeleting} - knowledgeBaseName={title} - /> - )} - - {id && ( - - )} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/index.ts deleted file mode 100644 index bed488707be..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/base-card/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/constants.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/components/constants.ts deleted file mode 100644 index 03fe178bdc7..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/constants.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const filterButtonClass = - 'w-full justify-between rounded-[10px] border-[var(--surface-6)] bg-[var(--white)] font-normal text-sm dark:border-[var(--border-muted)] dark:bg-[var(--surface-2)]' - -export const dropdownContentClass = - 'w-[220px] rounded-lg border-[var(--surface-6)] bg-[var(--white)] p-0 shadow-xs dark:border-[var(--border-muted)] dark:bg-[var(--surface-2)]' - -export const commandListClass = 'overflow-y-auto overflow-x-hidden' - -export type SortOption = 'name' | 'createdAt' | 'updatedAt' | 'docCount' -export type SortOrder = 'asc' | 'desc' - -export const SORT_OPTIONS = [ - { value: 'updatedAt-desc', label: 'Last Updated' }, - { value: 'createdAt-desc', label: 'Newest First' }, - { value: 'createdAt-asc', label: 'Oldest First' }, - { value: 'name-asc', label: 'Name (A-Z)' }, - { value: 'name-desc', label: 'Name (Z-A)' }, - { value: 'docCount-desc', label: 'Most Documents' }, - { value: 'docCount-asc', label: 'Least Documents' }, -] as const diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/components/index.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/components/index.ts index 92d91b2cd0f..7fa05975a72 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/components/index.ts @@ -1,4 +1,3 @@ -export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card' export { CreateBaseModal } from './create-base-modal' export { DeleteKnowledgeBaseModal } from './delete-knowledge-base-modal' export { EditKnowledgeBaseModal } from './edit-knowledge-base-modal' diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 5dfe44bff34..71ed2259ca9 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -26,7 +26,10 @@ import { Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import type { + MoveOptionNode, + SortableResource, +} from '@/app/workspace/[workspaceId]/components/folders' import { buildDescendantIndex, buildMoveOptions, @@ -37,6 +40,7 @@ import { nextUntitledFolderName, parseFolderedRowId, parseMoveOptionValue, + sortResources, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' @@ -53,7 +57,7 @@ import { knowledgeSortParams, knowledgeUrlKeys, } from '@/app/workspace/[workspaceId]/knowledge/search-params' -import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/sort' +import { filterKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/utils/filter' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' @@ -75,6 +79,11 @@ interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { docCount?: number } +/** A list row, resolved to the entity it refers to. */ +type KnowledgeResourceItem = + | { kind: 'base'; base: KnowledgeBaseWithDocCount } + | { kind: 'folder'; folder: WorkflowFolder } + const COLUMNS: ResourceColumn[] = [ { id: 'name', header: 'Name' }, { id: 'documents', header: 'Documents', widthMultiplier: 0.6 }, @@ -224,6 +233,8 @@ export function Knowledge() { const debouncedSearchQuery = useDebounce(urlSearchQuery, SEARCH_DEBOUNCE_MS) const { + sort: sortColumn, + dir: sortDirection, activeSort, onSort: onSortColumn, onClear: onClearSort, @@ -398,28 +409,10 @@ export function Knowledge() { const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) const needle = debouncedSearchQuery.trim().toLowerCase() - const searched = needle + return needle ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) : siblings - - const col = activeSort?.column ?? 'name' - const dir = activeSort?.direction ?? 'asc' - return [...searched].sort((a, b) => { - const aPinned = pinnedFolderIds.has(a.id) - const bPinned = pinnedFolderIds.has(b.id) - if (aPinned !== bPinned) return aPinned ? -1 : 1 - - let cmp = 0 - if (col === 'created') { - cmp = a.createdAt.getTime() - b.createdAt.getTime() - } else if (col === 'updated') { - cmp = a.updatedAt.getTime() - b.updatedAt.getTime() - } else { - cmp = a.name.localeCompare(b.name) - } - return dir === 'asc' ? cmp : -cmp - }) - }, [folders, currentFolderId, debouncedSearchQuery, activeSort, pinnedFolderIds]) + }, [folders, currentFolderId, debouncedSearchQuery]) const processedKBs = useMemo(() => { /** @@ -460,45 +453,7 @@ export function Knowledge() { result = result.filter((kb) => ownerFilter.includes(kb.userId)) } - const col = activeSort?.column ?? 'updated' - const dir = activeSort?.direction ?? 'desc' - return [...result].sort((a, b) => { - // Pinned bases float to the top of every sort/direction — pinning is a - // user-declared priority, not another sort key to be inverted by `desc`. - const aPinned = pinnedBaseIds.has(a.id) - const bPinned = pinnedBaseIds.has(b.id) - if (aPinned !== bPinned) return aPinned ? -1 : 1 - - let cmp = 0 - switch (col) { - case 'name': - cmp = a.name.localeCompare(b.name) - break - case 'documents': - cmp = - ((a as KnowledgeBaseWithDocCount).docCount || 0) - - ((b as KnowledgeBaseWithDocCount).docCount || 0) - break - case 'tokens': - cmp = (a.tokenCount || 0) - (b.tokenCount || 0) - break - case 'created': - cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - break - case 'updated': - cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - break - case 'connectors': - cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0) - break - case 'owner': - cmp = (membersById.get(a.userId)?.name ?? '').localeCompare( - membersById.get(b.userId)?.name ?? '' - ) - break - } - return dir === 'asc' ? cmp : -cmp - }) + return result }, [ knowledgeBases, currentFolderId, @@ -508,52 +463,112 @@ export function Knowledge() { connectorFilter, contentFilter, ownerFilter, - activeSort, - membersById, - pinnedBaseIds, ]) - const baseRows: ResourceRow[] = useMemo(() => { - const folderRows = visibleFolders.map((folder) => - folderRow(folder, { + /** + * Folders and bases sort as ONE list — a folder never outranks a base it ties with, so a + * pinned base reaches the top of the list rather than the top of the base section. + * + * Decorate-sort: each row's key + pinned flag is computed ONCE (O(N)) so the comparator + * never re-runs Date parsing or member lookups per comparison. Folders carry no document, + * token, or connector count, so those keys are `null` and land the folders last in both + * directions — matching the em-dash they show in those cells. + */ + const sortedEntries = useMemo(() => { + const entries: SortableResource[] = [] + + for (const folder of visibleFolders) { + entries.push({ + item: { kind: 'folder', folder }, pinned: pinnedFolderIds.has(folder.id), - cells: { - documents: { label: EMPTY_CELL_PLACEHOLDER }, - tokens: { label: EMPTY_CELL_PLACEHOLDER }, - connectors: { label: EMPTY_CELL_PLACEHOLDER }, - created: timeCell(folder.createdAt), - owner: ownerCell(folder.userId, membersById), - updated: timeCell(folder.updatedAt), - }, + name: folder.name, + key: + sortColumn === 'documents' || sortColumn === 'tokens' || sortColumn === 'connectors' + ? null + : sortColumn === 'created' + ? new Date(folder.createdAt).getTime() + : sortColumn === 'updated' + ? new Date(folder.updatedAt).getTime() + : sortColumn === 'owner' + ? (membersById.get(folder.userId)?.name ?? null) + : folder.name, }) - ) + } - const knowledgeBaseRows = processedKBs.map((kb) => { - const kbWithCount = kb as KnowledgeBaseWithDocCount - return { - id: kb.id, - cells: { - name: { - icon: KNOWLEDGE_BASE_ICON, - label: kb.name, - pinned: pinnedBaseIds.has(kb.id), - }, - documents: { - label: String(kbWithCount.docCount || 0), - }, - tokens: { - label: kb.tokenCount ? kb.tokenCount.toLocaleString() : '0', - }, - connectors: connectorCell(kb.connectorTypes), - created: timeCell(kb.createdAt), - owner: ownerCell(kb.userId, membersById), - updated: timeCell(kb.updatedAt), - }, - } - }) + for (const kb of processedKBs) { + entries.push({ + item: { kind: 'base', base: kb as KnowledgeBaseWithDocCount }, + pinned: pinnedBaseIds.has(kb.id), + name: kb.name, + key: + sortColumn === 'documents' + ? ((kb as KnowledgeBaseWithDocCount).docCount ?? 0) + : sortColumn === 'tokens' + ? (kb.tokenCount ?? 0) + : sortColumn === 'connectors' + ? (kb.connectorTypes?.length ?? 0) + : sortColumn === 'created' + ? new Date(kb.createdAt).getTime() + : sortColumn === 'updated' + ? new Date(kb.updatedAt).getTime() + : sortColumn === 'owner' + ? (membersById.get(kb.userId)?.name ?? null) + : kb.name, + }) + } - return [...folderRows, ...knowledgeBaseRows] - }, [visibleFolders, processedKBs, membersById, pinnedFolderIds, pinnedBaseIds]) + return sortResources(entries, sortDirection) + }, [ + visibleFolders, + processedKBs, + sortColumn, + sortDirection, + membersById, + pinnedFolderIds, + pinnedBaseIds, + ]) + + const baseRows: ResourceRow[] = useMemo( + () => + sortedEntries.map(({ item, pinned }): ResourceRow => { + if (item.kind === 'folder') { + return folderRow(item.folder, { + pinned, + cells: { + documents: { label: EMPTY_CELL_PLACEHOLDER }, + tokens: { label: EMPTY_CELL_PLACEHOLDER }, + connectors: { label: EMPTY_CELL_PLACEHOLDER }, + created: timeCell(item.folder.createdAt), + owner: ownerCell(item.folder.userId, membersById), + updated: timeCell(item.folder.updatedAt), + }, + }) + } + + const { base } = item + return { + id: base.id, + cells: { + name: { + icon: KNOWLEDGE_BASE_ICON, + label: base.name, + pinned, + }, + documents: { + label: String(base.docCount || 0), + }, + tokens: { + label: base.tokenCount ? base.tokenCount.toLocaleString() : '0', + }, + connectors: connectorCell(base.connectorTypes), + created: timeCell(base.createdAt), + owner: ownerCell(base.userId, membersById), + updated: timeCell(base.updatedAt), + }, + } + }), + [sortedEntries, membersById] + ) /** * Rename is layered over the built rows rather than folded into the builder above, so a @@ -951,8 +966,8 @@ export function Knowledge() { { id: 'tokens', label: 'Tokens' }, { id: 'connectors', label: 'Connectors' }, { id: 'created', label: 'Created' }, - { id: 'updated', label: 'Last Updated' }, { id: 'owner', label: 'Owner' }, + { id: 'updated', label: 'Last Updated' }, ], active: activeSort, onSort: onSortColumn, diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts index 996786083fd..7c9d45cb668 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -2,11 +2,13 @@ import type { QueryClient } from '@tanstack/react-query' import type { FolderApi } from '@/lib/api/contracts/folders' import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { KNOWLEDGE_BASE_LIST_STALE_TIME, knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' /** - * Prefetches the workspace's knowledge-bases list AND its knowledge-base folder tree under + * Prefetches the workspace's knowledge-bases list AND its knowledge-base folder tree — plus + * the pinned ids and members {@link prefetchResourceListChrome} covers — under * the same query keys the client `useKnowledgeBasesQuery` / `useFolders` hooks use (scope * `active`), so the list paints populated on first render. * @@ -43,5 +45,6 @@ export async function prefetchKnowledgeBases( }, staleTime: FOLDER_LIST_STALE_TIME, }), + prefetchResourceListChrome(queryClient, workspaceId, 'knowledge_base'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts new file mode 100644 index 00000000000..1656b04f25d --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/filter.ts @@ -0,0 +1,18 @@ +import type { KnowledgeBaseData } from '@/lib/knowledge/types' + +/** + * Filter knowledge bases by search query + */ +export function filterKnowledgeBases( + knowledgeBases: KnowledgeBaseData[], + searchQuery: string +): KnowledgeBaseData[] { + if (!searchQuery.trim()) { + return knowledgeBases + } + + const query = searchQuery.trim().toLowerCase() + return knowledgeBases.filter( + (kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query) + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts deleted file mode 100644 index 5c5df5059aa..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/utils/sort.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { KnowledgeBaseData } from '@/lib/knowledge/types' -import type { SortOption, SortOrder } from '../components/constants' - -interface KnowledgeBaseWithDocCount extends KnowledgeBaseData { - docCount?: number -} - -/** - * Sort knowledge bases by the specified field and order - */ -export function sortKnowledgeBases( - knowledgeBases: KnowledgeBaseData[], - sortBy: SortOption, - sortOrder: SortOrder -): KnowledgeBaseData[] { - return [...knowledgeBases].sort((a, b) => { - let comparison = 0 - - switch (sortBy) { - case 'name': - comparison = a.name.localeCompare(b.name) - break - case 'createdAt': - comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - break - case 'updatedAt': - comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - break - case 'docCount': - comparison = - ((a as KnowledgeBaseWithDocCount).docCount || 0) - - ((b as KnowledgeBaseWithDocCount).docCount || 0) - break - } - - return sortOrder === 'asc' ? comparison : -comparison - }) -} - -/** - * Filter knowledge bases by search query - */ -export function filterKnowledgeBases( - knowledgeBases: KnowledgeBaseData[], - searchQuery: string -): KnowledgeBaseData[] { - if (!searchQuery.trim()) { - return knowledgeBases - } - - const query = searchQuery.trim().toLowerCase() - return knowledgeBases.filter( - (kb) => kb.name.toLowerCase().includes(query) || kb.description?.toLowerCase().includes(query) - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts new file mode 100644 index 00000000000..5d9241aa23f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome.ts @@ -0,0 +1,54 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { PinnedItemApi, PinnedResourceType } from '@/lib/api/contracts/pinned-items' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { PINNED_ITEMS_STALE_TIME, pinnedItemKeys } from '@/hooks/queries/utils/pinned-item-keys' +import { + WORKSPACE_MEMBERS_STALE_TIME, + type WorkspaceMember, + workspaceKeys, +} from '@/hooks/queries/workspace' + +/** + * Prefetches the two lists every foldered resource page needs to paint a row completely, + * beyond the resources themselves. + * + * Pinned ids are not decoration: they are the list's primary sort key, so a page that paints + * before they land renders the whole list in the wrong order and then visibly re-sorts. Two + * lists are needed because a folder pins under `resourceType: 'folder'`, a different pin + * namespace from the resource beside it. + * + * Members back the Owner column; without them every owner cell paints empty and fills in + * after. Both are cheap and shared with the page's own list prefetch in one `Promise.all`. + */ +export async function prefetchResourceListChrome( + queryClient: QueryClient, + workspaceId: string, + resourceType: PinnedResourceType +): Promise { + const prefetchPinned = (type: PinnedResourceType) => + queryClient.prefetchQuery({ + queryKey: pinnedItemKeys.list(workspaceId, type), + queryFn: async () => { + const { pinnedItems } = await prefetchInternalJson<{ pinnedItems: PinnedItemApi[] }>( + `/api/pinned-items?workspaceId=${workspaceId}&resourceType=${type}` + ) + return pinnedItems + }, + staleTime: PINNED_ITEMS_STALE_TIME, + }) + + await Promise.all([ + prefetchPinned(resourceType), + prefetchPinned('folder'), + queryClient.prefetchQuery({ + queryKey: workspaceKeys.members(workspaceId), + queryFn: async () => { + const { members } = await prefetchInternalJson<{ members: WorkspaceMember[] }>( + `/api/workspaces/${workspaceId}/members` + ) + return members + }, + staleTime: WORKSPACE_MEMBERS_STALE_TIME, + }), + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts index 711754ff5e7..392202a2224 100644 --- a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -22,7 +22,9 @@ import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/ import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { knowledgeKeys } from '@/hooks/queries/utils/knowledge-keys' +import { pinnedItemKeys } from '@/hooks/queries/utils/pinned-item-keys' import { tableKeys } from '@/hooks/queries/utils/table-keys' +import { workspaceKeys } from '@/hooks/queries/workspace' import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' @@ -102,6 +104,52 @@ describe('workspace list prefetches', () => { }) }) + describe('resource-list chrome', () => { + /** + * Pinned ids are the list's primary sort key, so a page that paints without them renders + * the whole list in the wrong order and then visibly re-sorts. Members back the Owner + * column. Both must be primed on every foldered page, under the exact client keys. + */ + const chromeCases = [ + { name: 'files', run: prefetchFilesBrowser, resourceType: 'file' as const }, + { name: 'tables', run: prefetchTables, resourceType: 'table' as const }, + { name: 'knowledge', run: prefetchKnowledgeBases, resourceType: 'knowledge_base' as const }, + ] + + for (const { name, run, resourceType } of chromeCases) { + it(`primes pinned ids (${resourceType} + folder) and members for ${name}`, async () => { + const pinnedItems = [{ id: 'p-1', resourceId: 'r-1' }] + const members = [{ userId: 'u-1', name: 'Ada' }] + mockPrefetchInternalJson.mockImplementation(async (path: string) => { + if (path.startsWith('/api/pinned-items')) return { pinnedItems } + if (path.endsWith('/members')) return { members } + if (path.includes('/folders')) return { folders: [] } + return { success: true, files: [], data: { tables: [] } } + }) + const client = makeClient() + + await run(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=${resourceType}` + ) + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/pinned-items?workspaceId=${WORKSPACE_ID}&resourceType=folder` + ) + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/workspaces/${WORKSPACE_ID}/members` + ) + expect(client.getQueryData(pinnedItemKeys.list(WORKSPACE_ID, resourceType))).toEqual( + pinnedItems + ) + expect(client.getQueryData(pinnedItemKeys.list(WORKSPACE_ID, 'folder'))).toEqual( + pinnedItems + ) + expect(client.getQueryData(workspaceKeys.members(WORKSPACE_ID))).toEqual(members) + }) + } + }) + describe('prefetchHomeLists', () => { it('primes folder + file keys, mapping folder rows to the client shape', async () => { const folderRow = { diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx index 3bf5fafbdad..39db4ff9265 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx @@ -398,7 +398,11 @@ export function RecentlyDeleted() { cmp = a.deletedAt.getTime() - b.deletedAt.getTime() break } - return sortDirection === 'asc' ? cmp : -cmp + if (cmp !== 0) return sortDirection === 'asc' ? cmp : -cmp + // Ties fall back to A→Z regardless of direction, matching the foldered + // resource lists — otherwise rows tied on type or deletion time land in + // the arbitrary order the resource families were collected in. + return a.name.localeCompare(b.name) || a.id.localeCompare(b.id) }) const itemIds = new Set(items.map((item) => item.id)) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts index 196aa207bba..5a548885511 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -2,11 +2,13 @@ import type { QueryClient } from '@tanstack/react-query' import type { FolderApi } from '@/lib/api/contracts/folders' import type { TableDefinition } from '@/lib/table' import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { prefetchResourceListChrome } from '@/app/workspace/[workspaceId]/lib/prefetch-resource-list-chrome' import { FOLDER_LIST_STALE_TIME, folderKeys, mapFolder } from '@/hooks/queries/utils/folder-keys' import { TABLE_LIST_STALE_TIME, tableKeys } from '@/hooks/queries/utils/table-keys' /** - * Prefetches the workspace's tables list and its table folder tree under the same + * Prefetches the workspace's tables list and its table folder tree — plus the pinned ids and + * members {@link prefetchResourceListChrome} covers — under the same * query keys the client `useTablesList` / `useFolders` hooks use (scope `active`), * so the list paints populated on first render. Both are needed: a table row is * only placed correctly relative to the folder rows it sits beside, so @@ -39,5 +41,6 @@ export async function prefetchTables(queryClient: QueryClient, workspaceId: stri }, staleTime: FOLDER_LIST_STALE_TIME, }), + prefetchResourceListChrome(queryClient, workspaceId, 'table'), ]) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 81a44336bbd..b7b7dea07dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -27,7 +27,10 @@ import { Resource, timeCell, } from '@/app/workspace/[workspaceId]/components' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import type { + MoveOptionNode, + SortableResource, +} from '@/app/workspace/[workspaceId]/components/folders' import { buildDescendantIndex, buildMoveOptions, @@ -38,6 +41,7 @@ import { nextUntitledFolderName, parseFolderedRowId, parseMoveOptionValue, + sortResources, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' @@ -93,7 +97,7 @@ const ROOT_LABEL = 'Tables' const EMPTY_TABLES: TableDefinition[] = [] -/** The right-clicked row, resolved to the entity it refers to. */ +/** A list row (and the right-clicked row), resolved to the entity it refers to. */ type TableResourceItem = | { kind: 'table'; table: TableDefinition } | { kind: 'folder'; folder: WorkflowFolder } @@ -267,41 +271,12 @@ export function Tables() { const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) const needle = debouncedSearchTerm.trim().toLowerCase() - const searched = needle + return needle ? siblings.filter((folder) => folder.name.toLowerCase().includes(needle)) : siblings - - return [...searched].sort((a, b) => { - // Pinned folders float to the top of every sort/direction — pinning is a - // user-declared priority, not another sort key to be inverted by `desc`. - const aPinned = pinnedFolderIds.has(a.id) - const bPinned = pinnedFolderIds.has(b.id) - if (aPinned !== bPinned) return aPinned ? -1 : 1 - - /** - * Read from `activeSort`, not the raw params: `tablesSortParams` is defaulted, so - * `sortColumn` is never null and folders would sort newest-first on a clean URL while - * Files and Knowledge sort them A→Z. Folders also carry none of the table-specific - * columns, so `columns`/`rows`/`owner` fall back to name rather than an arbitrary order. - */ - const col = activeSort?.column ?? 'name' - const dir = activeSort?.direction ?? 'asc' - let cmp = 0 - if (col === 'created') { - cmp = a.createdAt.getTime() - b.createdAt.getTime() - } else if (col === 'updated') { - cmp = a.updatedAt.getTime() - b.updatedAt.getTime() - } else { - cmp = a.name.localeCompare(b.name) - } - return dir === 'asc' ? cmp : -cmp - }) - }, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds]) + }, [folders, currentFolderId, debouncedSearchTerm]) const processedTables = useMemo(() => { - // Same source as `visibleFolders` above, so the two blocks can never disagree on order. - const sortColumn = activeSort?.column ?? 'updated' - const sortDirection = activeSort?.direction ?? 'desc' const query = debouncedSearchTerm.trim().toLowerCase() /** * A `folderId` that no longer names an active folder — restored on its own out @@ -331,39 +306,7 @@ export function Tables() { if (ownerFilter.length > 0) { result = result.filter((t) => ownerFilter.includes(t.createdBy)) } - return [...result].sort((a, b) => { - // Pinned tables float to the top of every sort/direction — pinning is a - // user-declared priority, not another sort key to be inverted by `desc`. - const aPinned = pinnedTableIds.has(a.id) - const bPinned = pinnedTableIds.has(b.id) - if (aPinned !== bPinned) return aPinned ? -1 : 1 - - let cmp = 0 - switch (sortColumn) { - case 'name': - cmp = a.name.localeCompare(b.name) - break - case 'columns': - cmp = a.schema.columns.length - b.schema.columns.length - break - case 'rows': - cmp = a.rowCount - b.rowCount - break - case 'created': - cmp = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() - break - case 'updated': - cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime() - break - case 'owner': { - const aName = membersById.get(a.createdBy)?.name ?? '' - const bName = membersById.get(b.createdBy)?.name ?? '' - cmp = aName.localeCompare(bName) - break - } - } - return sortDirection === 'asc' ? cmp : -cmp - }) + return result }, [ tables, currentFolderId, @@ -372,56 +315,110 @@ export function Tables() { debouncedSearchTerm, rowCountFilter, ownerFilter, - sortColumn, - sortDirection, - membersById, - pinnedTableIds, ]) /** - * Folders first, then tables — folders are containers, so keeping them above - * the leaves survives every sort column and direction. + * Folders and tables sort as ONE list — a folder never outranks a table it ties with, so a + * pinned table reaches the top of the list rather than the top of the table section. + * + * Decorate-sort: each row's key + pinned flag is computed ONCE (O(N)) so the comparator + * never re-runs Date parsing or member lookups per comparison. Folders carry no column or + * row count, so those keys are `null` and land the folders last in both directions — + * matching the em-dash they show in those cells. */ - const baseRows: ResourceRow[] = useMemo(() => { - const folderRows = visibleFolders.map((folder) => - folderRow(folder, { + const sortedEntries = useMemo(() => { + const entries: SortableResource[] = [] + + for (const folder of visibleFolders) { + entries.push({ + item: { kind: 'folder', folder }, pinned: pinnedFolderIds.has(folder.id), - cells: { - columns: { label: EMPTY_CELL_PLACEHOLDER }, - rows: { label: EMPTY_CELL_PLACEHOLDER }, - created: timeCell(folder.createdAt), - owner: ownerCell(folder.userId, membersById), - updated: timeCell(folder.updatedAt), - }, + name: folder.name, + key: + sortColumn === 'columns' || sortColumn === 'rows' + ? null + : sortColumn === 'created' + ? new Date(folder.createdAt).getTime() + : sortColumn === 'updated' + ? new Date(folder.updatedAt).getTime() + : sortColumn === 'owner' + ? (membersById.get(folder.userId)?.name ?? null) + : folder.name, }) - ) + } - const tableRows = processedTables.map( - (table): ResourceRow => ({ - id: table.id, - cells: { - name: { - icon: , - label: table.name, - pinned: pinnedTableIds.has(table.id), - }, - columns: { - icon: , - label: String(table.schema.columns.length), - }, - rows: { - icon: , - label: String(table.rowCount), - }, - created: timeCell(table.createdAt), - owner: ownerCell(table.createdBy, membersById), - updated: timeCell(table.updatedAt), - }, + for (const table of processedTables) { + entries.push({ + item: { kind: 'table', table }, + pinned: pinnedTableIds.has(table.id), + name: table.name, + key: + sortColumn === 'columns' + ? table.schema.columns.length + : sortColumn === 'rows' + ? table.rowCount + : sortColumn === 'created' + ? new Date(table.createdAt).getTime() + : sortColumn === 'updated' + ? new Date(table.updatedAt).getTime() + : sortColumn === 'owner' + ? (membersById.get(table.createdBy)?.name ?? null) + : table.name, }) - ) + } + + return sortResources(entries, sortDirection) + }, [ + visibleFolders, + processedTables, + sortColumn, + sortDirection, + membersById, + pinnedFolderIds, + pinnedTableIds, + ]) - return [...folderRows, ...tableRows] - }, [visibleFolders, processedTables, membersById, pinnedFolderIds, pinnedTableIds]) + const baseRows: ResourceRow[] = useMemo( + () => + sortedEntries.map(({ item, pinned }): ResourceRow => { + if (item.kind === 'folder') { + return folderRow(item.folder, { + pinned, + cells: { + columns: { label: EMPTY_CELL_PLACEHOLDER }, + rows: { label: EMPTY_CELL_PLACEHOLDER }, + created: timeCell(item.folder.createdAt), + owner: ownerCell(item.folder.userId, membersById), + updated: timeCell(item.folder.updatedAt), + }, + }) + } + + const { table } = item + return { + id: table.id, + cells: { + name: { + icon: , + label: table.name, + pinned, + }, + columns: { + icon: , + label: String(table.schema.columns.length), + }, + rows: { + icon: , + label: String(table.rowCount), + }, + created: timeCell(table.createdAt), + owner: ownerCell(table.createdBy, membersById), + updated: timeCell(table.updatedAt), + }, + } + }), + [sortedEntries, membersById] + ) /** * Layered on top of {@link baseRows} rather than folded into it so a keystroke 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 649b523f9bd..cddd83baa79 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 @@ -18,6 +18,7 @@ import Link from 'next/link' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' 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' @@ -27,6 +28,56 @@ interface FileFolderFlyoutNode extends WorkspaceFileFolderApi { files: WorkspaceFileRecord[] } +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. + */ +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)) +} + +const FILE_FLYOUT_ICON = ( + +) + export function CollapsedFileFolderItems({ nodes, rootFiles, @@ -40,7 +91,22 @@ export function CollapsedFileFolderItems({ }) { return ( <> - {nodes.map((folder) => { + {fileFlyoutEntries(nodes, rootFiles ?? []).map((entry) => { + if (entry.kind === 'file') { + return ( + + + {FILE_FLYOUT_ICON} + {entry.name} + + + ) + } + + const folder = entry.folder const hasChildren = folder.children.length > 0 || folder.files.length > 0 if (!hasChildren) { @@ -61,59 +127,14 @@ export function CollapsedFileFolderItems({ - {folder.files.map((file) => ( - - - - {file.name} - - - ))} ) })} - {rootFiles?.map((file) => ( - - - - {file.name} - - - ))} ) } @@ -396,22 +417,7 @@ export function CollapsedWorkflowFlyoutItem({ ) } -export function CollapsedFolderItems({ - nodes, - workflowsByFolder, - workspaceId, - currentWorkflowId, - editingWorkflowId, - editingValue, - editInputRef, - isRenamingWorkflow, - onEditValueChange, - onEditKeyDown, - onEditBlur, - onWorkflowOpenInNewTab, - onWorkflowRename, - canRenameWorkflow, -}: { +interface CollapsedFolderItemsProps { nodes: FolderTreeNode[] workflowsByFolder: Record workspaceId: string @@ -426,7 +432,32 @@ export function CollapsedFolderItems({ onWorkflowOpenInNewTab?: (workflow: WorkflowMetadata) => void onWorkflowRename?: (workflow: WorkflowMetadata) => void canRenameWorkflow?: boolean -}) { +} + +/** + * Renders folder flyouts for one level of the collapsed sidebar. A folder's + * submenu interleaves its child folders and workflows by `sortOrder` — the same + * single ordering the expanded sidebar and this menu's own root level use, so a + * folder never jumps above a workflow the user dragged above it. + */ +export function CollapsedFolderItems(props: CollapsedFolderItemsProps) { + const { + nodes, + workflowsByFolder, + workspaceId, + currentWorkflowId, + editingWorkflowId, + editingValue, + editInputRef, + isRenamingWorkflow, + onEditValueChange, + onEditKeyDown, + onEditBlur, + onWorkflowOpenInNewTab, + onWorkflowRename, + canRenameWorkflow, + } = props + return ( <> {nodes.map((folder) => { @@ -449,42 +480,32 @@ export function CollapsedFolderItems({ {folder.name} - - {folderWorkflows.map((workflow) => ( - onWorkflowOpenInNewTab(workflow) : undefined - } - onRename={onWorkflowRename ? () => onWorkflowRename(workflow) : undefined} - canRename={canRenameWorkflow} - /> - ))} + {interleaveSiblings(folder.children, folderWorkflows).map((child) => + child.kind === 'folder' ? ( + + ) : ( + onWorkflowOpenInNewTab(child.workflow) + : undefined + } + onRename={onWorkflowRename ? () => onWorkflowRename(child.workflow) : undefined} + canRename={canRenameWorkflow} + /> + ) + )} ) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx index bb162d2c44d..ef8aab99bdb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/file-list/file-list.tsx @@ -7,47 +7,99 @@ import Link from 'next/link' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import type { WorkspaceFileFolderApi } from '@/hooks/queries/workspace-file-folders' -interface FileFolderNode extends WorkspaceFileFolderApi { - children: FileFolderNode[] - files: WorkspaceFileRecord[] -} +type FileTreeNode = + | { kind: 'folder'; id: string; name: string; children: FileTreeNode[] } + | { kind: 'file'; id: string; name: string; file: WorkspaceFileRecord } -function buildFileFolderTree( +/** + * Nests folders and files into one tree, ordering each level by name with + * folders and files interleaved. A folder is not hoisted above the file beside + * it — the Files page sorts them as one list, and a sidebar that partitioned + * them would order the same folder's contents differently from the page. + */ +function buildFileTree( folders: WorkspaceFileFolderApi[], files: WorkspaceFileRecord[], parentId: string | null = null -): FileFolderNode[] { - return folders - .filter((f) => (f.parentId ?? null) === parentId) - .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) - .map((folder) => ({ - ...folder, - children: buildFileFolderTree(folders, files, folder.id), - files: files - .filter((file) => (file.folderId ?? null) === folder.id) - .sort((a, b) => a.name.localeCompare(b.name)), - })) +): FileTreeNode[] { + const nodes: FileTreeNode[] = [ + ...folders + .filter((folder) => (folder.parentId ?? null) === parentId) + .map( + (folder): FileTreeNode => ({ + kind: 'folder', + id: folder.id, + name: folder.name, + children: buildFileTree(folders, files, folder.id), + }) + ), + ...files + .filter((file) => (file.folderId ?? null) === parentId) + .map((file): FileTreeNode => ({ kind: 'file', id: file.id, name: file.name, file })), + ] + return nodes.sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id)) } const INDENT_PER_LEVEL = 16 -interface FileFolderNodeItemProps { - node: FileFolderNode +/** Width of the folder row's chevron, so a file's icon lines up with a folder's. */ +const CHEVRON_WIDTH = 14 + +const FILE_ICON = ( + +) + +interface FileTreeNodeItemProps { + node: FileTreeNode workspaceId: string currentFileId: string | undefined pathname: string | null level: number } -const FileFolderNodeItem = memo(function FileFolderNodeItem({ +const FileTreeNodeItem = memo(function FileTreeNodeItem({ node, workspaceId, currentFileId, pathname, level, -}: FileFolderNodeItemProps) { +}: FileTreeNodeItemProps) { const [isExpanded, setIsExpanded] = useState(true) - const hasChildren = node.children.length > 0 || node.files.length > 0 + + if (node.kind === 'file') { + const href = `/workspace/${workspaceId}/files/${node.file.id}` + const isActive = currentFileId === node.file.id || pathname === href + return ( + + {FILE_ICON} + + {node.name} + + + ) + } + + const hasChildren = node.children.length > 0 return (
@@ -75,13 +127,9 @@ const FileFolderNodeItem = memo(function FileFolderNodeItem({ aria-hidden='true' > {isExpanded && hasChildren ? ( - <> - - + ) : ( - <> - - + )} @@ -89,53 +137,17 @@ const FileFolderNodeItem = memo(function FileFolderNodeItem({ - {isExpanded && ( - <> - {node.children.map((child) => ( - - ))} - {node.files.map((file) => { - const href = `/workspace/${workspaceId}/files/${file.id}` - const isActive = currentFileId === file.id || pathname === href - return ( - - - - {file.name} - - - ) - })} - - )} + {isExpanded && + node.children.map((child) => ( + + ))}
) }) @@ -155,20 +167,12 @@ export const FileList = memo(function FileList({ folders, files, }: FileListProps) { - const rootFolderNodes = useMemo(() => buildFileFolderTree(folders, files, null), [folders, files]) - - const rootFiles = useMemo( - () => - files - .filter((f) => (f.folderId ?? null) === null) - .sort((a, b) => a.name.localeCompare(b.name)), - [files] - ) + const rootNodes = useMemo(() => buildFileTree(folders, files, null), [folders, files]) return (
- {rootFolderNodes.map((node) => ( - ( + ))} - {rootFiles.map((file) => { - const href = `/workspace/${workspaceId}/files/${file.id}` - const isActive = currentFileId === file.id || pathname === href - return ( - - - - {file.name} - - - ) - })}
) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts index e75c6a33432..9f358b9a265 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/utils.ts @@ -1,6 +1,7 @@ import type { MothershipResource } from '@/lib/copilot/resource-types' import { getFolderMap } from '@/hooks/queries/utils/folder-cache' import { getWorkflows } from '@/hooks/queries/utils/workflow-cache' +import type { FolderTreeNode } from '@/stores/folders/types' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' /** @@ -105,6 +106,41 @@ export function compareByOrder ({ + kind: 'folder' as const, + id: node.id, + node, + sortOrder: node.sortOrder, + createdAt: node.createdAt, + })), + ...workflows.map((workflow) => ({ + kind: 'workflow' as const, + id: workflow.id, + workflow, + sortOrder: workflow.sortOrder, + createdAt: workflow.createdAt, + })), + ] + items.sort(compareByOrder) + return items +} + export function groupWorkflowsByFolder( workflows: WorkflowMetadata[] ): Record { diff --git a/apps/sim/hooks/queries/pinned-items.ts b/apps/sim/hooks/queries/pinned-items.ts index 1c9dd240413..5d271e181e3 100644 --- a/apps/sim/hooks/queries/pinned-items.ts +++ b/apps/sim/hooks/queries/pinned-items.ts @@ -15,17 +15,7 @@ import { type PinnedItemApi, type PinnedResourceType, } from '@/lib/api/contracts' - -export const PINNED_ITEMS_STALE_TIME = 60 * 1000 - -export const pinnedItemKeys = { - all: ['pinnedItems'] as const, - lists: () => [...pinnedItemKeys.all, 'list'] as const, - /** Prefix covering every per-resourceType list in a workspace — the invalidation target. */ - workspaceLists: (workspaceId?: string) => [...pinnedItemKeys.lists(), workspaceId ?? ''] as const, - list: (workspaceId?: string, resourceType?: PinnedResourceType) => - [...pinnedItemKeys.workspaceLists(workspaceId), resourceType ?? ''] as const, -} +import { PINNED_ITEMS_STALE_TIME, pinnedItemKeys } from '@/hooks/queries/utils/pinned-item-keys' async function fetchPinnedItems( workspaceId: string, diff --git a/apps/sim/hooks/queries/utils/pinned-item-keys.ts b/apps/sim/hooks/queries/utils/pinned-item-keys.ts new file mode 100644 index 00000000000..e6682e7e04c --- /dev/null +++ b/apps/sim/hooks/queries/utils/pinned-item-keys.ts @@ -0,0 +1,21 @@ +import type { PinnedResourceType } from '@/lib/api/contracts/pinned-items' + +/** + * Lives in this standalone module — like {@link file://./folder-keys.ts} and + * {@link file://./table-keys.ts} — so a server prefetch can hydrate the pinned lists without + * importing `@/hooks/queries/pinned-items`, which pulls the contracts barrel and the + * optimistic-mutation machinery in with it. The contract import here is type-only, so it + * erases at build time. + */ + +/** Shared with the server prefetch so a hydrated list and a client fetch never disagree. */ +export const PINNED_ITEMS_STALE_TIME = 60 * 1000 + +export const pinnedItemKeys = { + all: ['pinnedItems'] as const, + lists: () => [...pinnedItemKeys.all, 'list'] as const, + /** Prefix covering every per-resourceType list in a workspace — the invalidation target. */ + workspaceLists: (workspaceId?: string) => [...pinnedItemKeys.lists(), workspaceId ?? ''] as const, + list: (workspaceId?: string, resourceType?: PinnedResourceType) => + [...pinnedItemKeys.workspaceLists(workspaceId), resourceType ?? ''] as const, +} diff --git a/apps/sim/lib/url-state/sort-params.ts b/apps/sim/lib/url-state/sort-params.ts index eff86afe6c7..c7b5257a491 100644 --- a/apps/sim/lib/url-state/sort-params.ts +++ b/apps/sim/lib/url-state/sort-params.ts @@ -27,10 +27,10 @@ export interface DefaultedSortParams { /** * Nullable sort params for lists where "no active sort" is behaviorally - * distinct from explicitly sorting by the fallback column (e.g. files: with no - * sort, files order by updated/desc but folders by name/asc). The params carry - * no defaults, so an explicit selection always persists in the URL and - * clearing writes `null` to strip both. + * distinct from explicitly sorting by the fallback column (e.g. document + * chunks: with no sort the query omits `sortBy` entirely and the server's own + * order applies). The params carry no defaults, so an explicit selection always + * persists in the URL and clearing writes `null` to strip both. */ export interface NullableSortParams { columns: readonly C[]