diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts index 67e333a5c9b..7a2b3d88f50 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -1,60 +1,114 @@ import type { ElementType } from 'react' +import { folderAncestorChain } from '@/lib/folders/tree' import type { BreadcrumbEditing, BreadcrumbItem, DropdownOption, } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' -import type { WorkflowFolder } from '@/stores/folders/types' -export interface FolderBreadcrumbItemsOptions { - /** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */ +/** + * Structural rather than `WorkflowFolder` so the Files tree — same `folder` table, own routes + * and row type (see `servedFolderResourceTypeSchema` in `@/lib/api/contracts/folders`) — shares + * this code path instead of forking it. + */ +export interface BreadcrumbFolder { + id: string + name: string + parentId: string | null +} + +const EMPTY_CHAIN: never[] = [] + +/** + * Root-first ancestor chain, that folder last, or empty when it does not reach the root — + * where {@link folderAncestorChain} would hand back the part it walked. + * + * A partial path is not a shorter path, it is a wrong one: it claims the deepest folder it + * resolved sits at the workspace root. Falling back to the root title is the honest render. + * Completeness is `chain[0].parentId === null`, which also rejects a cycle. Callers must pass + * the complete tree — see `FolderAncestors.foldersResolved`. + */ +export function breadcrumbFolderChain( + folderId: string | null | undefined, + folderById: ReadonlyMap +): T[] { + const chain = folderAncestorChain(folderId, (id) => folderById.get(id)) + return chain.length === 0 || chain[0].parentId === null ? chain : EMPTY_CHAIN +} + +interface FolderBreadcrumbItemsBase { + /** Root crumb label — the page's own name ("Knowledge bases", "Tables"). */ rootLabel: string rootIcon?: ElementType - /** Root-first ancestor chain of the open folder, from `useFolderNavigation`. */ - breadcrumbs: WorkflowFolder[] + /** Root-first ancestor chain, from {@link folderAncestorChain}. */ + breadcrumbs: BreadcrumbFolder[] /** Called with the folder to open, or `null` for the workspace root. */ onNavigate: (folderId: string | null) => void +} + +/** A list page: the deepest folder is where you are, so its crumb carries the rename and menu. */ +interface FolderListBreadcrumbOptions extends FolderBreadcrumbItemsBase { /** Menu attached to the open folder's crumb (rename, delete, …). */ currentFolderActions?: DropdownOption[] /** Inline rename bound to the open folder's crumb. */ currentFolderEditing?: BreadcrumbEditing + trailing?: never +} + +/** A detail page: the open resource is where you are, so every folder crumb navigates. */ +interface FolderDetailBreadcrumbOptions extends FolderBreadcrumbItemsBase { + /** + * Crumbs appended after the folder trail — the resource open on a detail page, plus + * anything nested under it (a knowledge base's document, that document's chunk). + */ + trailing: BreadcrumbItem[] + currentFolderActions?: never + currentFolderEditing?: never } /** - * Converts a folder ancestor chain into the `BreadcrumbItem[]` that `Resource.Header` - * renders. + * The two modes are disjoint by construction rather than by convention: an open-folder rename + * or menu acts on the folder you are inside, which on a detail page you are not. Expressed as + * a union so passing both is a compile error instead of a handler that silently never fires. + */ +export type FolderBreadcrumbItemsOptions = + | FolderListBreadcrumbOptions + | FolderDetailBreadcrumbOptions + +const NO_TRAILING_CRUMBS: BreadcrumbItem[] = [] + +/** + * Builds the `BreadcrumbItem[]` for a list page (`Tables / Reports`) or a detail page + * (`Tables / Reports / Q3`). * * A plain builder rather than a component: `Resource.Header` already owns every piece of * breadcrumb chrome — the root-crumb "Path" popover, segment width allocation, overflow - * tooltips, and the rule that a single-element trail renders as a plain page title. A - * sibling crumb component would have to fork all of it, which is exactly what this shared - * directory exists to prevent. - * - * The trail always starts with the root crumb, so at the workspace root the result has - * length 1 and the header renders the page title unchanged. + * tooltips, and the rule that a single-element trail renders as a plain page title. A sibling + * crumb component would have to fork all of it, which is what this directory exists to prevent. */ -export function folderBreadcrumbItems({ - rootLabel, - rootIcon, - breadcrumbs, - onNavigate, - currentFolderActions, - currentFolderEditing, -}: FolderBreadcrumbItemsOptions): BreadcrumbItem[] { +export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): BreadcrumbItem[] { + const { rootLabel, rootIcon, breadcrumbs, onNavigate } = options + const trailing = options.trailing ?? NO_TRAILING_CRUMBS + const items: BreadcrumbItem[] = [ { label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) }, ] breadcrumbs.forEach((folder, index) => { - const isCurrent = index === breadcrumbs.length - 1 - /** The open folder is where you already are, so its crumb is not a navigation target. */ + /** Where you already are — and on a detail page that is a trailing crumb, not a folder. */ + const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1 items.push({ label: folder.name, - onClick: isCurrent ? undefined : () => onNavigate(folder.id), - dropdownItems: isCurrent && currentFolderActions?.length ? currentFolderActions : undefined, - editing: isCurrent ? currentFolderEditing : undefined, + onClick: isOpenFolder ? undefined : () => onNavigate(folder.id), + dropdownItems: + isOpenFolder && options.currentFolderActions?.length + ? options.currentFolderActions + : undefined, + editing: isOpenFolder ? options.currentFolderEditing : undefined, }) }) + items.push(...trailing) + return items } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts new file mode 100644 index 00000000000..f370e98762c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts @@ -0,0 +1,54 @@ +import type { ElementType } from 'react' +import { Database, File as FileIcon, Table as TableIcon } from '@sim/emcn/icons' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import { folderListHref } from '@/app/workspace/[workspaceId]/components/folders/search-params' + +/** + * The foldered resources that render a `Resource.Header` breadcrumb trail. A subset of + * {@link FolderResourceType}: workflows are foldered too, but they live in the editor sidebar + * rather than on a list page with a header. + */ +export type FolderedHeaderResourceType = Extract< + FolderResourceType, + 'file' | 'knowledge_base' | 'table' +> + +export interface FolderedResourceHeaderMeta { + /** Root crumb label, and the page title at the workspace root. */ + rootLabel: string + /** Icon on the root crumb, which is also what opens the header's "Path" popover. */ + rootIcon: ElementType + /** Path segment of the list page under `/workspace/[workspaceId]/`. */ + listSegment: string +} + +/** + * The per-resource facts a foldered header needs, in one place. + * + * Each was previously restated at every surface rendering that resource — list page, detail + * page, and for knowledge bases the document and chunk views — which is how one trail ends up + * labelled differently depending on which page you reached it from. + */ +export const FOLDERED_RESOURCE_HEADERS: Record< + FolderedHeaderResourceType, + FolderedResourceHeaderMeta +> = { + file: { rootLabel: 'Files', rootIcon: FileIcon, listSegment: 'files' }, + knowledge_base: { rootLabel: 'Knowledge bases', rootIcon: Database, listSegment: 'knowledge' }, + table: { rootLabel: 'Tables', rootIcon: TableIcon, listSegment: 'tables' }, +} + +/** + * Href of a foldered resource's list page, opened at `folderId` or at its workspace root. + * + * Detail pages navigate to a different route, so their breadcrumb folder crumbs cannot use the + * nuqs setter — it only mutates the query of the current path. + */ +export function folderedResourceListHref( + resourceType: FolderedHeaderResourceType, + workspaceId: string, + folderId: string | null +): string { + const { listSegment } = FOLDERED_RESOURCE_HEADERS[resourceType] + return folderListHref(`/workspace/${workspaceId}/${listSegment}`, folderId) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts index 9818053d6e4..994dfaf5e49 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts @@ -2,7 +2,11 @@ * @vitest-environment node */ import { describe, expect, it, vi } from 'vitest' -import { folderBreadcrumbItems } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs' +import { folderAncestorChain } from '@/lib/folders/tree' +import { + breadcrumbFolderChain, + folderBreadcrumbItems, +} from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs' import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components/folders/folder-naming' import { folderRowId, @@ -238,4 +242,92 @@ describe('folderBreadcrumbItems', () => { expect(items[2].editing).toBe(currentFolderEditing) expect(items[2].dropdownItems).toBe(currentFolderActions) }) + + it('appends the trailing crumbs of a detail page after the folder chain', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Tables', + breadcrumbs: [makeFolder('root', null, { name: 'Alpha' })], + onNavigate: vi.fn(), + trailing: [{ label: 'Q3' }], + }) + expect(items.map((item) => item.label)).toEqual(['Tables', 'Alpha', 'Q3']) + }) + + it('makes every folder crumb navigable once a trailing crumb is where you are', () => { + const onNavigate = vi.fn() + const items = folderBreadcrumbItems({ + rootLabel: 'Tables', + breadcrumbs: [makeFolder('root'), makeFolder('leaf', 'root')], + onNavigate, + trailing: [{ label: 'Q3' }], + }) + + items[2].onClick?.() + expect(onNavigate).toHaveBeenCalledWith('leaf') + }) + + it('leaves the deepest folder crumb plain on a detail page — the rename and menu are list-only', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Tables', + breadcrumbs: [makeFolder('leaf')], + onNavigate: vi.fn(), + trailing: [{ label: 'Q3' }], + }) + + expect(items[1].dropdownItems).toBeUndefined() + expect(items[1].editing).toBeUndefined() + }) +}) + +describe('breadcrumbFolderChain', () => { + function mapOf(...folders: WorkflowFolder[]) { + return new Map(folders.map((folder) => [folder.id, folder])) + } + + it('returns nothing at the workspace root', () => { + expect(breadcrumbFolderChain(null, mapOf(makeFolder('a')))).toEqual([]) + expect(breadcrumbFolderChain(undefined, mapOf(makeFolder('a')))).toEqual([]) + }) + + it('walks parentId up to the root and returns the chain root-first', () => { + const chain = breadcrumbFolderChain( + 'leaf', + mapOf(makeFolder('root'), makeFolder('mid', 'root'), makeFolder('leaf', 'mid')) + ) + expect(chain.map((folder) => folder.id)).toEqual(['root', 'mid', 'leaf']) + }) + + it('collapses the whole chain when an ancestor does not resolve, rather than skipping a level', () => { + const chain = breadcrumbFolderChain('leaf', mapOf(makeFolder('leaf', 'gone'))) + expect(chain).toEqual([]) + }) + + it('collapses a parent cycle the DB permits between constraint checks, rather than hanging', () => { + const chain = breadcrumbFolderChain('a', mapOf(makeFolder('a', 'b'), makeFolder('b', 'a'))) + expect(chain).toEqual([]) + }) + + it('collapses a chain the folder map is still too incomplete to root', () => { + const chain = breadcrumbFolderChain( + 'leaf', + mapOf(makeFolder('mid', 'root'), makeFolder('leaf', 'mid')) + ) + expect(chain).toEqual([]) + }) +}) + +describe('folderAncestorChain', () => { + it('keeps the part it walked when a link does not resolve — the breadcrumb rule is a wrapper', () => { + const folders: Record = { leaf: makeFolder('leaf', 'gone') } + const chain = folderAncestorChain('leaf', (id) => folders[id]) + expect(chain.map((folder) => folder.id)).toEqual(['leaf']) + }) + + it('stops on a cycle instead of looping forever', () => { + const folders: Record = { + a: makeFolder('a', 'b'), + b: makeFolder('b', 'a'), + } + expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a']) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index 784f44af960..aee06919742 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -1,11 +1,16 @@ -export type { FolderBreadcrumbItemsOptions } from './folder-breadcrumbs' -export { folderBreadcrumbItems } from './folder-breadcrumbs' +export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs' +export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs' export { FolderContextMenu } from './folder-context-menu' export { nextUntitledFolderName } from './folder-naming' export type { FolderRowOptions } from './folder-row' export { folderRow } from './folder-row' export type { FolderedRowKind, ParsedFolderedRowId } from './folder-row-id' export { folderRowId, parseFolderedRowId } from './folder-row-id' +export type { + FolderedHeaderResourceType, + FolderedResourceHeaderMeta, +} from './foldered-resources' +export { FOLDERED_RESOURCE_HEADERS, folderedResourceListHref } from './foldered-resources' export type { BuildMoveOptionsParams, MoveOptionNode } from './move-options' export { buildDescendantIndex, @@ -18,6 +23,8 @@ export { export type { SortableResource } from './resource-sort' export { sortResources } from './resource-sort' export { folderNavParsers, folderNavUrlKeys } from './search-params' +export type { FolderAncestors, UseFolderAncestorsOptions } from './use-folder-ancestors' +export { useFolderAncestors } from './use-folder-ancestors' export type { FolderNavigation, UseFolderNavigationOptions } from './use-folder-navigation' export { useFolderNavigation } from './use-folder-navigation' export type { UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts index ceaca0e9b4d..583f45d6a9c 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/search-params.ts @@ -23,3 +23,13 @@ export const folderNavUrlKeys = { history: 'push', clearOnDefault: true, } as const + +/** + * Href of a foldered list page opened at `folderId`, or of its workspace root when `null`. + * + * Lives here so a hand-built link cannot drift from {@link folderNavParsers} on the wire key. + */ +export function folderListHref(listPath: string, folderId: string | null): string { + if (!folderId) return listPath + return `${listPath}?${new URLSearchParams({ folderId })}` +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-ancestors.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-ancestors.ts new file mode 100644 index 00000000000..5ea6a97cf8c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-ancestors.ts @@ -0,0 +1,85 @@ +'use client' + +import { useMemo } from 'react' +import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' +import { breadcrumbFolderChain } from '@/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs' +import { useFolders } from '@/hooks/queries/folders' +import type { WorkflowFolder } from '@/stores/folders/types' + +export interface UseFolderAncestorsOptions { + resourceType: ServedFolderResourceType + workspaceId?: string + /** + * The folder to build the chain for — the open folder on a list page, or the resource's + * own `folderId` on a detail page. + */ + folderId: string | null | undefined + /** + * Set `false` on a surface that renders no breadcrumb trail — the embedded table view — so + * it does not fetch a folder tree it will never show. + */ + enabled?: boolean +} + +export interface FolderAncestors { + /** + * Root-first ancestor chain of `folderId`, that folder last. Empty at the workspace root, + * and empty while the folder list is still loading or when the id no longer resolves (a + * deleted folder, a stale bookmark, a resource moved by someone else) — callers render the + * root trail rather than a path that skips a level. + */ + ancestors: WorkflowFolder[] + /** Every active folder in this resource's tree, as returned by the folders API. */ + folders: WorkflowFolder[] + folderById: Map + /** + * Whether `folders`/`folderById` can be trusted to be the COMPLETE set for this workspace. + * + * Deliberately exposed instead of `isLoading`, which is a footgun here: it is false for a + * disabled query (no `workspaceId`), false for an errored one, and — because `useFolders` + * sets `keepPreviousData` — false while the previous workspace's folders are still on screen + * during a switch. A caller deciding "this resource's `folderId` does not resolve, so treat it + * as an orphan" off `isLoading` would dump every foldered row at the root in all three. + */ + foldersResolved: boolean +} + +const EMPTY_FOLDERS: WorkflowFolder[] = [] + +/** + * The folder tree for one resource type, plus the ancestor chain of a single folder in it. + * + * Shared by {@link useFolderNavigation} (which passes the URL's open folder) and by detail + * pages (which pass the open resource's own `folderId`), so a table's header trail and the + * table list's header trail are built from the same tree by the same walk. + */ +export function useFolderAncestors({ + resourceType, + workspaceId, + folderId, + enabled, +}: UseFolderAncestorsOptions): FolderAncestors { + const { + data: folders = EMPTY_FOLDERS, + isSuccess, + isPlaceholderData, + } = useFolders(workspaceId, { resourceType, enabled }) + + const folderById = useMemo(() => { + const byId = new Map() + for (const folder of folders) byId.set(folder.id, folder) + return byId + }, [folders]) + + const ancestors = useMemo( + () => breadcrumbFolderChain(folderId, folderById), + [folderId, folderById] + ) + + return { + ancestors, + folders, + folderById, + foldersResolved: isSuccess && !isPlaceholderData, + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts index 71a9d01e9e3..19eb8f3e7d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts @@ -1,48 +1,28 @@ 'use client' -import { useCallback, useEffect, useMemo } from 'react' +import { useCallback, useEffect } from 'react' import { useQueryStates } from 'nuqs' import type { ServedFolderResourceType } from '@/lib/api/contracts/folders' import { folderNavParsers, folderNavUrlKeys, } from '@/app/workspace/[workspaceId]/components/folders/search-params' -import { useFolders } from '@/hooks/queries/folders' -import type { WorkflowFolder } from '@/stores/folders/types' +import { + type FolderAncestors, + useFolderAncestors, +} from '@/app/workspace/[workspaceId]/components/folders/use-folder-ancestors' export interface UseFolderNavigationOptions { resourceType: ServedFolderResourceType workspaceId?: string } -export interface FolderNavigation { +export interface FolderNavigation extends FolderAncestors { /** The open folder, or `null` at the workspace root. */ currentFolderId: string | null setCurrentFolderId: (folderId: string | null) => void - /** - * Root-first ancestor chain of the open folder, the open folder last. Empty at the root, - * and empty while the folder list is still loading or when the id no longer resolves (a - * deleted folder or a stale bookmark) — callers fall back to the root listing rather than - * rendering a broken trail. - */ - breadcrumbs: WorkflowFolder[] - /** Every active folder in this resource's tree, as returned by the folders API. */ - folders: WorkflowFolder[] - folderById: Map - /** - * Whether `folders`/`folderById` can be trusted to be the COMPLETE set for this workspace. - * - * Deliberately exposed instead of `isLoading`, which is a footgun here: it is false for a - * disabled query (no `workspaceId`), false for an errored one, and — because `useFolders` - * sets `keepPreviousData` — false while the previous workspace's folders are still on screen - * during a switch. A caller deciding "this resource's `folderId` does not resolve, so treat it - * as an orphan" off `isLoading` would dump every foldered row at the root in all three. - */ - foldersResolved: boolean } -const EMPTY_FOLDERS: WorkflowFolder[] = [] - /** * URL-backed folder navigation for a foldered resource list. Deliberately * resourceType-agnostic — the Workflows, Files, Knowledge, and Tables trees are separate @@ -61,21 +41,12 @@ export function useFolderNavigation({ folderNavUrlKeys ) - const { - data: folders = EMPTY_FOLDERS, - isSuccess, - isPlaceholderData, - } = useFolders(workspaceId, { resourceType }) - - /** - * The folder list is only trustworthy enough to evict a `folderId` when the query has - * actually succeeded for THIS workspace. `isLoading` alone is not that signal: it is false - * for a disabled query (no `workspaceId`), false for an errored one, and — because - * `useFolders` sets `keepPreviousData` — false while showing the previous workspace's - * folders during a workspace switch. In all three the list is empty or stale, and healing - * off it would throw away a perfectly good folder. - */ - const foldersResolved = isSuccess && !isPlaceholderData + const ancestry = useFolderAncestors({ + resourceType, + workspaceId, + folderId: currentFolderId, + }) + const { folderById, foldersResolved } = ancestry const setCurrentFolderId = useCallback( (folderId: string | null) => { @@ -84,12 +55,6 @@ export function useFolderNavigation({ [setFolderParams] ) - const folderById = useMemo(() => { - const byId = new Map() - for (const folder of folders) byId.set(folder.id, folder) - return byId - }, [folders]) - /** * Heals a `?folderId=` that no longer resolves — a bookmark to a folder since deleted, or a * link from someone whose workspace it was not. @@ -114,36 +79,5 @@ export function useFolderNavigation({ void setFolderParams({ folderId: null }, { history: 'replace' }) }, [foldersResolved, currentFolderId, folderById, setFolderParams]) - const breadcrumbs = useMemo(() => { - if (!currentFolderId) return EMPTY_FOLDERS - - /** - * Walks up via `parentId` rather than splitting a materialized path — the generic - * folder table stores no path — and guards against a cycle, which the DB permits - * between constraint checks. An unresolvable link collapses the whole trail so the - * header falls back to the root title instead of rendering a partial path. - */ - const chain: WorkflowFolder[] = [] - const seen = new Set() - let cursor: string | null = currentFolderId - - while (cursor && !seen.has(cursor)) { - seen.add(cursor) - const folder: WorkflowFolder | undefined = folderById.get(cursor) - if (!folder) return EMPTY_FOLDERS - chain.unshift(folder) - cursor = folder.parentId - } - - return chain - }, [currentFolderId, folderById]) - - return { - currentFolderId, - setCurrentFolderId, - breadcrumbs, - folders, - folderById, - foldersResolved, - } + return { ...ancestry, currentFolderId, setCurrentFolderId } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index bb716412d30..a79347de310 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -8,7 +8,6 @@ import { Columns2, type ComboboxOption, Eye, - File as FilesIcon, Folder, FolderPlus, Loader, @@ -67,6 +66,10 @@ import type { SortableResource, } from '@/app/workspace/[workspaceId]/components/folders' import { + breadcrumbFolderChain, + FOLDERED_RESOURCE_HEADERS, + folderBreadcrumbItems, + folderedResourceListHref, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, sortResources, @@ -136,6 +139,8 @@ type FileListEntry = const logger = createLogger('Files') +const FILES_HEADER = FOLDERED_RESOURCE_HEADERS.file + const FOLDER_ICON = /** Folders' value in the `type` column — also their sort key when that column is active. */ @@ -432,7 +437,6 @@ export function Files() { ) : null const folderById = useMemo(() => new Map(folders.map((folder) => [folder.id, folder])), [folders]) - const currentFolder = currentFolderId ? (folderById.get(currentFolderId) ?? null) : null const folderSizeMap = useMemo(() => { const directSize = new Map() @@ -475,7 +479,6 @@ export function Files() { for (const folder of folders) getTotal(folder.id) return totalSize }, [files, folders]) - const currentFolderPath = currentFolder?.path ?? null const visibleFolders = useMemo(() => { const siblings = folders.filter((folder) => (folder.parentId ?? null) === currentFolderId) @@ -1104,11 +1107,7 @@ export function Files() { if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) { setIsDirty(false) setSaveStatus('idle') - router.push( - currentFolderId - ? `/workspace/${workspaceId}/files?folderId=${currentFolderId}` - : `/workspace/${workspaceId}/files` - ) + router.push(folderedResourceListHref('file', workspaceId, currentFolderId)) } } catch (err) { logger.error('Failed to delete file:', err) @@ -1226,55 +1225,40 @@ export function Files() { await downloadArchive({ fileIds: selectedFileIds, folderIds: selectedFolderIds }) }, [selectedFileIds, selectedFolderIds, files, handleDownload, downloadArchive, workspaceId]) - const fileDetailBreadcrumbs = useMemo(() => { + const fileDetailBreadcrumbs = useMemo((): BreadcrumbItem[] => { if (!selectedFile) return [] - const folderBreadcrumbs: BreadcrumbItem[] = [] - const visitedFolderIds = new Set() - let folderId = selectedFile.folderId - - while (folderId && !visitedFolderIds.has(folderId)) { - visitedFolderIds.add(folderId) - const folder = folderById.get(folderId) - if (!folder) break - - folderBreadcrumbs.unshift({ - label: folder.name, - onClick: () => - handleNavigateFromFileDetail(`/workspace/${workspaceId}/files?folderId=${folder.id}`), - }) - folderId = folder.parentId - } - - return [ - { - label: 'Files', - onClick: () => handleNavigateFromFileDetail(`/workspace/${workspaceId}/files`), - }, - ...folderBreadcrumbs, - { - label: selectedFile.name, - editing: headerRename.editingId - ? { - isEditing: true, - value: headerRename.editValue, - onChange: headerRename.setEditValue, - onSubmit: headerRename.submitRename, - onCancel: headerRename.cancelRename, - } - : undefined, - dropdownItems: [ - { label: 'Download', icon: Download, onClick: handleDownloadSelected }, - ...(canEdit - ? [ - { label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename }, - { label: 'Share', icon: Send, onClick: handleShareSelected }, - { label: 'Delete', icon: Trash, onClick: handleDeleteSelected }, - ] - : []), - ], - }, - ] + return folderBreadcrumbItems({ + rootLabel: FILES_HEADER.rootLabel, + rootIcon: FILES_HEADER.rootIcon, + breadcrumbs: breadcrumbFolderChain(selectedFile.folderId, folderById), + onNavigate: (folderId) => + handleNavigateFromFileDetail(folderedResourceListHref('file', workspaceId, folderId)), + trailing: [ + { + label: selectedFile.name, + editing: headerRename.editingId + ? { + isEditing: true, + value: headerRename.editValue, + onChange: headerRename.setEditValue, + onSubmit: headerRename.submitRename, + onCancel: headerRename.cancelRename, + } + : undefined, + dropdownItems: [ + { label: 'Download', icon: Download, onClick: handleDownloadSelected }, + ...(canEdit + ? [ + { label: 'Rename', icon: Pencil, onClick: handleStartHeaderRename }, + { label: 'Share', icon: Send, onClick: handleShareSelected }, + { label: 'Delete', icon: Trash, onClick: handleDeleteSelected }, + ] + : []), + ], + }, + ], + }) }, [ selectedFile, folderById, @@ -1295,12 +1279,9 @@ export function Files() { setIsDirty(false) setSaveStatus('idle') setPreviewMode('editor') - const folderId = selectedFileRef.current?.folderId + const folderId = selectedFileRef.current?.folderId ?? null const targetUrl = - pendingFileNavigationUrlRef.current ?? - (folderId - ? `/workspace/${workspaceId}/files?folderId=${folderId}` - : `/workspace/${workspaceId}/files`) + pendingFileNavigationUrlRef.current ?? folderedResourceListHref('file', workspaceId, folderId) pendingFileNavigationUrlRef.current = null router.push(targetUrl) } @@ -1750,75 +1731,85 @@ export function Files() { ] ) - const handleNavigateToFiles = useCallback(() => { - void setFilesParams({ folderId: null, new: null }) - }, [setFilesParams]) - - const loadingBreadcrumbs = useMemo( - (): BreadcrumbItem[] => [ - { label: 'Files', onClick: handleNavigateToFiles }, - { label: '…', terminal: true }, - ], - [handleNavigateToFiles] + const handleNavigateToListFolder = useCallback( + (folderId: string | null) => { + void setFilesParams({ folderId, new: null }) + }, + [setFilesParams] ) - const breadcrumbRenameRef = useRef(breadcrumbRename) - breadcrumbRenameRef.current = breadcrumbRename + const listFolderChain = useMemo( + () => breadcrumbFolderChain(currentFolderId, folderById), + [currentFolderId, folderById] + ) - const listBreadcrumbs = useMemo(() => { - const breadcrumbs: BreadcrumbItem[] = [{ label: 'Files', onClick: handleNavigateToFiles }] - if (!currentFolderPath) return breadcrumbs + /** + * The trail while a file's content loads. Holds the URL's open folder so arriving from a + * list page inside `A/B` doesn't collapse to `Files / …` and jump back out once the file + * lands; a cold deep-link has no `?folderId=` and no loaded file, so it starts at the root. + * + * Renders on the file *detail* route, so its crumbs navigate through the router like + * {@link fileDetailBreadcrumbs} — a nuqs write would only requery this file's own URL. + */ + const loadingBreadcrumbs = useMemo( + (): BreadcrumbItem[] => + folderBreadcrumbItems({ + rootLabel: FILES_HEADER.rootLabel, + rootIcon: FILES_HEADER.rootIcon, + breadcrumbs: listFolderChain, + onNavigate: (folderId) => + handleNavigateFromFileDetail(folderedResourceListHref('file', workspaceId, folderId)), + trailing: [{ label: '…', terminal: true }], + }), + [listFolderChain, handleNavigateFromFileDetail, workspaceId] + ) - const segments = currentFolderPath.split('/') - let parentId: string | null = null - for (let i = 0; i < segments.length; i++) { - const segment = segments[i] - const folder = folders.find( - (item) => item.name === segment && (item.parentId ?? null) === parentId - ) - if (!folder) continue - const isCurrentFolder = folder.id === currentFolderId - breadcrumbs.push({ - label: folder.name, - onClick: isCurrentFolder - ? undefined - : () => void setFilesParams({ folderId: folder.id, new: null }), - editing: - isCurrentFolder && breadcrumbRenameRef.current.editingId === folder.id + const openListFolder = currentFolderId ? folderById.get(currentFolderId) : undefined + + const listBreadcrumbs = useMemo( + (): BreadcrumbItem[] => + folderBreadcrumbItems({ + rootLabel: FILES_HEADER.rootLabel, + rootIcon: FILES_HEADER.rootIcon, + breadcrumbs: listFolderChain, + onNavigate: handleNavigateToListFolder, + currentFolderEditing: + openListFolder && breadcrumbRename.editingId === openListFolder.id ? { isEditing: true, - value: breadcrumbRenameRef.current.editValue, - onChange: breadcrumbRenameRef.current.setEditValue, - onSubmit: breadcrumbRenameRef.current.submitRename, - onCancel: breadcrumbRenameRef.current.cancelRename, + value: breadcrumbRename.editValue, + onChange: breadcrumbRename.setEditValue, + onSubmit: breadcrumbRename.submitRename, + onCancel: breadcrumbRename.cancelRename, } : undefined, - dropdownItems: - isCurrentFolder && (canEdit || userPermissions.isLoading) + currentFolderActions: + openListFolder && (canEdit || userPermissions.isLoading) ? [ { label: 'Rename', icon: Pencil, disabled: !canEdit, - onClick: () => breadcrumbRenameRef.current.startRename(folder.id, folder.name), + onClick: () => + breadcrumbRename.startRename(openListFolder.id, openListFolder.name), }, ] : undefined, - }) - parentId = folder.id - } - return breadcrumbs - }, [ - currentFolderPath, - currentFolderId, - folders, - handleNavigateToFiles, - setFilesParams, - canEdit, - userPermissions.isLoading, - breadcrumbRename.editingId, - breadcrumbRename.editValue, - ]) + }), + [ + listFolderChain, + openListFolder, + handleNavigateToListFolder, + canEdit, + userPermissions.isLoading, + breadcrumbRename.editingId, + breadcrumbRename.editValue, + breadcrumbRename.setEditValue, + breadcrumbRename.submitRename, + breadcrumbRename.cancelRename, + breadcrumbRename.startRename, + ] + ) const memberOptions: ComboboxOption[] = useMemo( () => @@ -2035,7 +2026,7 @@ export function Files() { if (fileIdFromRoute && !selectedFile && isLoading) { return ( - +
@@ -2052,7 +2043,7 @@ export function Files() { } @@ -2119,8 +2110,8 @@ export function Files() { > diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx index 4309bf32eba..974a0896848 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -21,6 +21,12 @@ import type { SortConfig, } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, Resource } from '@/app/workspace/[workspaceId]/components' +import { + FOLDERED_RESOURCE_HEADERS, + folderBreadcrumbItems, + folderedResourceListHref, + useFolderAncestors, +} from '@/app/workspace/[workspaceId]/components/folders' import { ChunkContextMenu, ChunkEditor, @@ -129,7 +135,7 @@ export function Document({ knowledgeBaseName, documentName, }: DocumentProps) { - const { workspaceId } = useParams() + const workspaceId = useParams().workspaceId as string const router = useRouter() const [ { @@ -145,6 +151,13 @@ export function Document({ const { knowledgeBase } = useKnowledgeBase(knowledgeBaseId) const { document: documentData, error: documentError } = useDocument(knowledgeBaseId, documentId) + /** The base's folder trail, so this route's header matches the base's and the list's. */ + const { ancestors: folderChain } = useFolderAncestors({ + resourceType: 'knowledge_base', + workspaceId, + folderId: knowledgeBase?.folderId, + }) + const [showTagsModal, setShowTagsModal] = useState(false) /** @@ -490,13 +503,65 @@ export function Document({ [guardDirtyAction, navigateToChunk] ) - const handleNavToKB = useCallback(() => { - router.push(`/workspace/${workspaceId}/knowledge`) - }, [router, workspaceId]) + /** + * Confirms before a crumb navigates away from an unsaved chunk — a route change unmounts the + * editor, so the edit is gone with no way back. + * + * Gated on the editor being open rather than on `isDirty` alone: `UnsavedChangesModal` mounts + * only alongside the editor, but `isDirty` outlives a URL-driven unmount (browser Back off an + * edited chunk), where guarding would raise a modal nothing renders and deaden the crumb. + */ + const guardRouteChange = useCallback( + (navigate: () => void) => { + if (isCreatingNewChunk || selectedChunkId) guardDirtyAction(navigate) + else navigate() + }, + [isCreatingNewChunk, selectedChunkId, guardDirtyAction] + ) + + const handleNavToFolder = useCallback( + (folderId: string | null) => { + guardRouteChange(() => + router.push(folderedResourceListHref('knowledge_base', workspaceId, folderId)) + ) + }, + [guardRouteChange, router, workspaceId] + ) const handleNavToKBDetail = useCallback(() => { - router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`) - }, [router, workspaceId, knowledgeBaseId]) + guardRouteChange(() => router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)) + }, [guardRouteChange, router, workspaceId, knowledgeBaseId]) + + /** + * `Knowledge Base / …the base's folders / / `. Every view on this route is that + * trail with a different last crumb — the document, a chunk, an error, a loading placeholder + * — so it is built once here rather than restated per view. + */ + const documentTrail = useCallback( + (last: BreadcrumbItem, onDocumentClick?: () => void): BreadcrumbItem[] => + folderBreadcrumbItems({ + rootLabel: FOLDERED_RESOURCE_HEADERS.knowledge_base.rootLabel, + rootIcon: FOLDERED_RESOURCE_HEADERS.knowledge_base.rootIcon, + breadcrumbs: folderChain, + onNavigate: handleNavToFolder, + trailing: [ + { label: knowledgeBaseCrumbLabel, icon: Database, onClick: handleNavToKBDetail }, + /** Omitted when the document IS the last crumb — you are already on it. */ + ...(onDocumentClick + ? [{ label: documentCrumbLabel, icon: DocumentIcon, onClick: onDocumentClick }] + : []), + last, + ], + }), + [ + folderChain, + handleNavToFolder, + handleNavToKBDetail, + knowledgeBaseCrumbLabel, + documentCrumbLabel, + DocumentIcon, + ] + ) const handleStartDocRename = useCallback(() => { docRename.startRename(documentId, effectiveDocumentName) @@ -508,24 +573,10 @@ export function Document({ const breadcrumbs = useMemo( () => - combinedError - ? [ - { label: 'Knowledge bases', icon: Database, onClick: handleNavToKB }, - { - label: knowledgeBaseCrumbLabel, - icon: Database, - onClick: handleNavToKBDetail, - }, - { label: 'Error' }, - ] - : [ - { label: 'Knowledge bases', icon: Database, onClick: handleNavToKB }, - { - label: knowledgeBaseCrumbLabel, - icon: Database, - onClick: handleNavToKBDetail, - }, - { + documentTrail( + combinedError + ? { label: 'Error', terminal: true } + : { label: documentCrumbLabel, icon: DocumentIcon, editing: docRename.editingId @@ -547,13 +598,11 @@ export function Document({ ] : []), ], - }, - ], + } + ), [ combinedError, - handleNavToKB, - handleNavToKBDetail, - knowledgeBaseCrumbLabel, + documentTrail, documentCrumbLabel, DocumentIcon, docRename.editingId, @@ -987,58 +1036,23 @@ export function Document({ ? 'Create Chunk' : 'Save' - const editorBreadcrumbBase = useMemo( - () => [ - { label: 'Knowledge bases', icon: Database, onClick: handleNavToKB }, - { - label: knowledgeBaseCrumbLabel, - icon: Database, - onClick: handleNavToKBDetail, - }, - { label: documentCrumbLabel, icon: DocumentIcon, onClick: handleBackAttempt }, - ], - [ - handleNavToKB, - handleNavToKBDetail, - knowledgeBaseCrumbLabel, - documentCrumbLabel, - DocumentIcon, - handleBackAttempt, - ] - ) - const newChunkBreadcrumbs = useMemo( - () => [...editorBreadcrumbBase, { label: 'New Chunk', terminal: true }], - [editorBreadcrumbBase] + () => documentTrail({ label: 'New Chunk', terminal: true }, handleBackAttempt), + [documentTrail, handleBackAttempt] ) const editChunkBreadcrumbs = useMemo( - () => [ - ...editorBreadcrumbBase, - { label: selectedChunk ? `Chunk #${selectedChunk.chunkIndex}` : '', terminal: true }, - ], - [editorBreadcrumbBase, selectedChunk] + () => + documentTrail( + { label: selectedChunk ? `Chunk #${selectedChunk.chunkIndex}` : '', terminal: true }, + handleBackAttempt + ), + [documentTrail, handleBackAttempt, selectedChunk] ) const loadingBreadcrumbs = useMemo( - () => [ - { label: 'Knowledge bases', icon: Database, onClick: handleNavToKB }, - { - label: knowledgeBaseCrumbLabel, - icon: Database, - onClick: handleNavToKBDetail, - }, - { label: documentCrumbLabel, icon: DocumentIcon, onClick: handleClearSelectedChunk }, - { label: '…', terminal: true }, - ], - [ - handleNavToKB, - handleNavToKBDetail, - knowledgeBaseCrumbLabel, - documentCrumbLabel, - DocumentIcon, - handleClearSelectedChunk, - ] + () => documentTrail({ label: '…', terminal: true }, handleClearSelectedChunk), + [documentTrail, handleClearSelectedChunk] ) const handleSaveClick = useCallback(() => { diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx index f73a9167d08..ed67b33a791 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx @@ -8,6 +8,9 @@ import { type ChromeActionSpec, ResourceChromeFallback, } from '@/app/workspace/[workspaceId]/components' +import { FOLDERED_RESOURCE_HEADERS } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources' + +const KNOWLEDGE_HEADER = FOLDERED_RESOURCE_HEADERS.knowledge_base const COLUMNS = [ { id: 'content', header: 'Content' }, @@ -19,7 +22,7 @@ const COLUMNS = [ const ACTIONS: ChromeActionSpec[] = [{ text: 'New chunk', icon: Plus, variant: 'primary' }] const BREADCRUMBS: BreadcrumbItem[] = [ - { label: 'Knowledge bases', icon: Database, onClick: noop }, + { label: KNOWLEDGE_HEADER.rootLabel, icon: Database, onClick: noop }, { label: '…', icon: Database }, { label: '…', terminal: true }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 43255e696f7..651f69c4074 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -51,6 +51,12 @@ import type { SortConfig, } from '@/app/workspace/[workspaceId]/components' import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' +import { + FOLDERED_RESOURCE_HEADERS, + folderBreadcrumbItems, + folderedResourceListHref, + useFolderAncestors, +} from '@/app/workspace/[workspaceId]/components/folders' import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components' import { ActionBar, @@ -449,6 +455,23 @@ export function KnowledgeBase({ const knowledgeBaseCrumbLabel = knowledgeBase?.name || passedKnowledgeBaseName || '…' const error = knowledgeBaseError || documentsError + /** + * The base's own folder trail, so the header reads `Knowledge Base / Research / Papers` + * exactly as the list does one level up. + */ + const { ancestors: folderChain } = useFolderAncestors({ + resourceType: 'knowledge_base', + workspaceId, + folderId: knowledgeBase?.folderId, + }) + + const handleNavigateToFolder = useCallback( + (folderId: string | null) => { + router.push(folderedResourceListHref('knowledge_base', workspaceId, folderId)) + }, + [router, workspaceId] + ) + const totalPages = Math.ceil(pagination.total / pagination.limit) /** @@ -870,64 +893,99 @@ export function KnowledgeBase({ setContextMenuDocument(null) }, [closeContextMenu]) - const breadcrumbs: BreadcrumbItem[] = [ - { - label: 'Knowledge bases', - icon: Database, - onClick: () => router.push(`/workspace/${workspaceId}/knowledge`), - }, - { - label: knowledgeBaseCrumbLabel, - icon: Database, - editing: kbRename.editingId - ? { - isEditing: true, - value: kbRename.editValue, - onChange: kbRename.setEditValue, - onSubmit: kbRename.submitRename, - onCancel: kbRename.cancelRename, - disabled: kbRename.isSaving, - } - : undefined, - dropdownItems: [ - ...(userPermissions.canEdit || userPermissions.isLoading - ? [ - { - label: 'Rename', - icon: Pencil, - disabled: !userPermissions.canEdit, - onClick: () => kbRename.startRename(id, knowledgeBaseName), - }, - { - label: 'Tags', - icon: TagIcon, - disabled: !userPermissions.canEdit, - onClick: () => setShowTagsModal(true), - }, - { - label: 'Delete', - icon: Trash, - disabled: !userPermissions.canEdit, - onClick: () => setShowDeleteDialog(true), - }, - ] - : []), - ], - }, - ] - - const headerActions: ResourceAction[] = [ - ...(userPermissions.canEdit || userPermissions.isLoading - ? [ + const breadcrumbs: BreadcrumbItem[] = useMemo( + () => + folderBreadcrumbItems({ + rootLabel: FOLDERED_RESOURCE_HEADERS.knowledge_base.rootLabel, + rootIcon: FOLDERED_RESOURCE_HEADERS.knowledge_base.rootIcon, + breadcrumbs: folderChain, + onNavigate: handleNavigateToFolder, + trailing: [ { - text: 'New connector', - icon: Plus, - disabled: !userPermissions.canEdit, - onSelect: () => setShowAddConnectorModal(true), + label: knowledgeBaseCrumbLabel, + icon: Database, + editing: kbRename.editingId + ? { + isEditing: true, + value: kbRename.editValue, + onChange: kbRename.setEditValue, + onSubmit: kbRename.submitRename, + onCancel: kbRename.cancelRename, + disabled: kbRename.isSaving, + } + : undefined, + dropdownItems: [ + ...(userPermissions.canEdit || userPermissions.isLoading + ? [ + { + label: 'Rename', + icon: Pencil, + disabled: !userPermissions.canEdit, + onClick: () => kbRename.startRename(id, knowledgeBaseName), + }, + { + label: 'Tags', + icon: TagIcon, + disabled: !userPermissions.canEdit, + onClick: () => setShowTagsModal(true), + }, + { + label: 'Delete', + icon: Trash, + disabled: !userPermissions.canEdit, + onClick: () => setShowDeleteDialog(true), + }, + ] + : []), + ], }, - ] - : []), - ] + ], + }), + [ + folderChain, + handleNavigateToFolder, + knowledgeBaseCrumbLabel, + knowledgeBaseName, + id, + kbRename.editingId, + kbRename.editValue, + kbRename.isSaving, + kbRename.setEditValue, + kbRename.submitRename, + kbRename.cancelRename, + kbRename.startRename, + userPermissions.canEdit, + userPermissions.isLoading, + ] + ) + + const headerActions: ResourceAction[] = useMemo( + () => [ + ...(userPermissions.canEdit || userPermissions.isLoading + ? [ + { + text: 'New connector', + icon: Plus, + disabled: !userPermissions.canEdit, + onSelect: () => setShowAddConnectorModal(true), + }, + ] + : []), + { + text: 'New documents', + icon: Plus, + onSelect: handleAddDocuments, + disabled: userPermissions.canEdit !== true, + variant: 'primary', + }, + ], + [ + userPermissions.canEdit, + userPermissions.isLoading, + setShowAddConnectorModal, + handleAddDocuments, + ] + ) const sortConfig: SortConfig = useMemo( () => ({ @@ -1181,19 +1239,10 @@ export function KnowledgeBase({ <> folderBreadcrumbItems({ rootLabel: ROOT_BREADCRUMB_LABEL, - rootIcon: Database, + rootIcon: FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon, breadcrumbs, onNavigate: setCurrentFolderId, currentFolderEditing: @@ -1106,7 +1113,7 @@ export function Knowledge() { <> { - router.push(`/workspace/${workspaceId}/tables`) - }, [router, workspaceId]) + /** + * The table's own folder trail, so the header reads `Tables / Reports / Q3` exactly as the + * list does one level up — and as a file's header does. Skipped in embedded mode, which + * renders no header at all. + */ + const { ancestors: folderChain } = useFolderAncestors({ + resourceType: 'table', + workspaceId, + folderId: tableData?.folderId, + enabled: !embedded, + }) + + const handleNavigateToFolder = useCallback( + (folderId: string | null) => { + router.push(folderedResourceListHref('table', workspaceId, folderId)) + }, + [router, workspaceId] + ) const handleStartTableRename = useCallback(() => { const data = tableDataRef.current @@ -1078,55 +1099,63 @@ export function Table({ } const breadcrumbs = useMemo( - (): BreadcrumbItem[] => [ - { label: 'Tables', onClick: handleNavigateBack }, - // While the table loads, mirror this route's loading.tsx (terminal "…" crumb) - // so no empty-label / orphaned-chevron frame renders in between. - tableData - ? { - label: tableData.name, - editing: tableHeaderRename.editingId - ? { - isEditing: true, - value: tableHeaderRename.editValue, - onChange: tableHeaderRename.setEditValue, - onSubmit: tableHeaderRename.submitRename, - onCancel: tableHeaderRename.cancelRename, - } - : undefined, - dropdownItems: [ - { - label: 'Rename', - icon: Pencil, - onClick: handleStartTableRename, - }, - // Reachable with the flag off when something is locked, so an - // admin can always clear locks (the route allows clearing). - ...(userPermissions.canAdmin && - (tableLocksEnabled || lockedNouns(tableData.locks).length > 0) - ? [ - { - label: 'Lock settings', - icon: Lock, - onClick: () => setShowLockSettings(true), - }, - ] - : []), - { - label: 'Delete', - icon: Trash, - onClick: onRequestDeleteTable, - disabled: userPermissions.canEdit !== true || tableData.locks.deleteLocked, - }, - ], - } - : { label: '…', terminal: true }, - ], + (): BreadcrumbItem[] => + folderBreadcrumbItems({ + rootLabel: FOLDERED_RESOURCE_HEADERS.table.rootLabel, + rootIcon: FOLDERED_RESOURCE_HEADERS.table.rootIcon, + breadcrumbs: folderChain, + onNavigate: handleNavigateToFolder, + trailing: [ + // While the table loads, mirror this route's loading.tsx (terminal "…" crumb) + // so no empty-label / orphaned-chevron frame renders in between. + tableData + ? { + label: tableData.name, + editing: tableHeaderRename.editingId + ? { + isEditing: true, + value: tableHeaderRename.editValue, + onChange: tableHeaderRename.setEditValue, + onSubmit: tableHeaderRename.submitRename, + onCancel: tableHeaderRename.cancelRename, + } + : undefined, + dropdownItems: [ + { + label: 'Rename', + icon: Pencil, + onClick: handleStartTableRename, + }, + // Reachable with the flag off when something is locked, so an + // admin can always clear locks (the route allows clearing). + ...(userPermissions.canAdmin && + (tableLocksEnabled || lockedNouns(tableData.locks).length > 0) + ? [ + { + label: 'Lock settings', + icon: Lock, + onClick: () => setShowLockSettings(true), + }, + ] + : []), + { + label: 'Delete', + icon: Trash, + onClick: onRequestDeleteTable, + disabled: userPermissions.canEdit !== true || tableData.locks.deleteLocked, + }, + ], + } + : { label: '…', terminal: true }, + ], + }), [ - handleNavigateBack, + folderChain, + handleNavigateToFolder, userPermissions.canAdmin, userPermissions.canEdit, tableData, + tableLocksEnabled, tableHeaderRename.editingId, tableHeaderRename.editValue, tableHeaderRename.setEditValue, @@ -1332,7 +1361,7 @@ export function Table({ {!embedded && ( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index d6638cb3179..7a95bbd3ae8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -34,6 +34,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, folderRow, @@ -93,7 +94,7 @@ const COLUMNS: ResourceColumn[] = [ ] /** Root label for breadcrumbs and the "move to workspace root" destination. */ -const ROOT_LABEL = 'Tables' +const ROOT_LABEL = FOLDERED_RESOURCE_HEADERS.table.rootLabel const EMPTY_TABLES: TableDefinition[] = [] @@ -133,7 +134,7 @@ export function Tables() { const { currentFolderId, setCurrentFolderId, - breadcrumbs: folderChain, + ancestors: folderChain, folders, folderById, foldersResolved, @@ -512,6 +513,7 @@ export function Tables() { folderBreadcrumbItems({ breadcrumbs: folderChain, rootLabel: ROOT_LABEL, + rootIcon: FOLDERED_RESOURCE_HEADERS.table.rootIcon, onNavigate: setCurrentFolderId, currentFolderActions, currentFolderEditing, @@ -1073,7 +1075,7 @@ export function Tables() { <> , separator = ' / ' ): string | null { - if (!folderId) return null - - const segments: string[] = [] - const visited = new Set() - let currentFolderId: string | null | undefined = folderId - - while (currentFolderId) { - if (visited.has(currentFolderId)) break - visited.add(currentFolderId) - const folder: WorkflowFolder | undefined = folders[currentFolderId] - if (!folder) break - segments.unshift(folder.name) - currentFolderId = folder.parentId - } - + const segments = folderAncestorChain(folderId, (id) => folders[id]).map((folder) => folder.name) return segments.length > 0 ? segments.join(separator) : null } diff --git a/apps/sim/lib/folders/tree.ts b/apps/sim/lib/folders/tree.ts index 8e70dc3c4a7..2f99133c38d 100644 --- a/apps/sim/lib/folders/tree.ts +++ b/apps/sim/lib/folders/tree.ts @@ -38,32 +38,54 @@ export function getChildFolders( .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) } +/** The minimum a node must expose to be walked upward. */ +export interface FolderAncestorNode { + id: string + parentId: string | null +} + /** - * Root-first ancestor chain of `folderId`, as folder objects — callers need the ids (cycle - * checks, expanding each ancestor), not just the names, which is why this is distinct from - * the string-returning `getFolderPath` in `@/hooks/queries/utils/folder-tree`. + * Root-first ancestor chain of `folderId`, that folder last. The one upward walk in the app — + * every folder path, breadcrumb trail, and drop-target check goes through it. + * + * `lookup` rather than a fixed container because the folder index exists in three shapes + * (a `Record` from `useFolderMap`, a `Map` built by `useFolderAncestors`, the Files tree's own + * rows), and generic over the node so callers keep their own row type. * - * `visited` is not defensive padding. The client folder map is written optimistically by - * `useReorderFolders`, which sets `parentId` without validating the result, so a cycle is - * reachable in cache even though the server rejects one. Without the guard this loops - * forever and hangs the tab. + * Truncates at the first unresolvable link, returning what it walked. `visited` is not + * defensive padding: the client folder map is written optimistically by `useReorderFolders`, + * which sets `parentId` without validating the result, so a cycle is reachable in cache even + * though the server rejects one. Without the guard this loops forever and hangs the tab. */ -export function getFolderPath( - folders: Record, - folderId: string -): WorkflowFolder[] { - const path: WorkflowFolder[] = [] +export function folderAncestorChain( + folderId: string | null | undefined, + lookup: (id: string) => T | undefined +): T[] { + const chain: T[] = [] const visited = new Set() - let currentId: string | null = folderId + let currentId: string | null | undefined = folderId - while (currentId && folders[currentId] && !visited.has(currentId)) { + while (currentId && !visited.has(currentId)) { visited.add(currentId) - const folder: WorkflowFolder = folders[currentId] - path.unshift(folder) + const folder = lookup(currentId) + if (!folder) break + chain.unshift(folder) currentId = folder.parentId } - return path + return chain +} + +/** + * Root-first ancestor chain as folder objects — callers need the ids (cycle checks, expanding + * each ancestor), not just the names, which is why this is distinct from the string-returning + * `getFolderPath` in `@/hooks/queries/utils/folder-tree`. + */ +export function getFolderPath( + folders: Record, + folderId: string +): WorkflowFolder[] { + return folderAncestorChain(folderId, (id) => folders[id]) } /**