From 4997440346da5db6d2c9f32df4b141edbd950af2 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 18:06:41 -0700 Subject: [PATCH 1/4] fix(folders): show the folder trail in table and knowledge base headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table or knowledge base opened from inside a folder rendered `Tables / name`, dropping every folder between it and the root — while a file's header showed the full `Files / docs / name` path. The detail pages never read the resource's own `folderId`, so the trail could not include it. Converge all six foldered surfaces on one builder instead of fixing the two headers in place: - `folderAncestorChain(folderId, lookup)` in `lib/folders/tree.ts` is now the single upward walk. Both `getFolderPath` variants delegate to it; the two lock predicates deliberately keep their inline walks, which short-circuit at the first locked ancestor on a per-row render path. - `folderBreadcrumbItems` takes a `trailing` slot for detail pages, as a discriminated union so an open-folder rename cannot be passed alongside it and silently dropped. - `useFolderAncestors` owns the tree plus the `foldersResolved` staleness rule; `useFolderNavigation` now delegates to it. - `FOLDERED_RESOURCE_HEADERS` owns each resource's root label, root icon, and list path, which seven sites previously restated. Files' list trail moved off splitting the materialized `path` string, which could not tell two same-named siblings apart, onto the shared parentId walk. Also fixes the Files loading trail, whose folder crumbs used the nuqs setter while rendering on the file detail route — appending `?folderId=` to the open file's own URL instead of navigating to the list. --- .../components/folders/folder-breadcrumbs.ts | 102 ++++++-- .../components/folders/foldered-resources.ts | 55 +++++ .../components/folders/folders.test.ts | 94 +++++++- .../[workspaceId]/components/folders/index.ts | 11 +- .../components/folders/search-params.ts | 10 + .../folders/use-folder-ancestors.ts | 85 +++++++ .../folders/use-folder-navigation.ts | 58 +---- .../workspace/[workspaceId]/files/files.tsx | 228 +++++++++--------- .../knowledge/[id]/[documentId]/document.tsx | 138 +++++------ .../[workspaceId]/knowledge/[id]/base.tsx | 185 ++++++++------ .../[workspaceId]/knowledge/knowledge.tsx | 7 +- .../recently-deleted/recently-deleted.tsx | 7 +- .../[workspaceId]/tables/[tableId]/table.tsx | 129 ++++++---- .../workspace/[workspaceId]/tables/tables.tsx | 6 +- apps/sim/hooks/queries/utils/folder-tree.ts | 21 +- apps/sim/lib/folders/tree.ts | 56 +++-- 16 files changed, 774 insertions(+), 418 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-ancestors.ts 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..ba0d7645fc6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -1,28 +1,91 @@ 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 { +/** + * The only thing a breadcrumb trail needs from a folder tree node. Structural rather than + * `WorkflowFolder` so the Files tree builds its trail through this same code path instead of + * forking one: file folders live in the same `folder` table, but they are served by their own + * routes and carry their own row type (see the `servedFolderResourceTypeSchema` docs in + * `@/lib/api/contracts/folders` for why that split exists and is staying). + */ +export interface BreadcrumbFolder { + id: string + name: string + parentId: string | null +} + +/** + * Root-first ancestor chain for a breadcrumb trail, that folder last. Empty at the workspace + * root — and empty for a chain that does not reach the root, where {@link folderAncestorChain} + * would hand back the part it walked. + * + * A header falls back to the root title rather than rendering a trail that silently skips a + * level: a partial path is not a shorter path, it is a wrong one, claiming the deepest folder + * it resolved sits at the workspace root. A chain is complete exactly when its first element + * has no parent, which also rejects a cycle (the DB permits one between constraint checks). + * + * `folderById` must therefore be the complete tree — see `FolderAncestors.foldersResolved`; + * a partially loaded map reads as an orphan and collapses. + */ +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 +} + +const EMPTY_CHAIN: never[] = [] + +interface FolderBreadcrumbItemsBase { /** Root crumb label — the page's own name ("Knowledge Base", "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 +} + +/** + * 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[] = [] + /** * Converts a folder ancestor chain into the `BreadcrumbItem[]` that `Resource.Header` - * renders. + * renders, 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 @@ -30,31 +93,32 @@ export interface FolderBreadcrumbItemsOptions { * 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 + * The trail always starts with the root crumb, so a list page at the workspace root returns * length 1 and the header renders the page title unchanged. */ -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..5f0cdb2d43f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts @@ -0,0 +1,55 @@ +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 of these was previously restated at every surface that renders the resource — the list + * page, the detail page, and (for knowledge bases) the document and chunk views — which is how + * the same trail ends up with a different label or icon depending on which page you reached it + * from. Adding a foldered resource is an entry here plus its pages, never a fifth literal. + */ +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..abfe48a6b80 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,13 +1,13 @@ '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 { useFolderAncestors } from '@/app/workspace/[workspaceId]/components/folders/use-folder-ancestors' import type { WorkflowFolder } from '@/stores/folders/types' export interface UseFolderNavigationOptions { @@ -41,8 +41,6 @@ export interface FolderNavigation { 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 +59,11 @@ 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 { ancestors, folders, folderById, foldersResolved } = useFolderAncestors({ + resourceType, + workspaceId, + folderId: currentFolderId, + }) const setCurrentFolderId = useCallback( (folderId: string | null) => { @@ -84,12 +72,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,34 +96,10 @@ 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, + breadcrumbs: ancestors, folders, folderById, foldersResolved, diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index bb716412d30..94a52066eb7 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,88 @@ 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. Reuses the open folder from the URL, so arriving + * from a list page inside `A/B` holds `Files / A / B / …` instead of collapsing to + * `Files / …` and jumping back out once the file lands. A cold deep-link carries no + * `?folderId=`, and the file whose folder it would name has not loaded yet, so that case + * still starts at the root. + * + * Rendered only on the file *detail* route, so its folder crumbs leave this path through + * the router exactly as {@link fileDetailBreadcrumbs} does — the nuqs setter would append + * `?folderId=` to the still-open file's own URL and go nowhere. + */ + 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 +2029,7 @@ export function Files() { if (fileIdFromRoute && !selectedFile && isLoading) { return ( - +
@@ -2052,7 +2046,7 @@ export function Files() { } @@ -2119,8 +2113,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..7f947549b9b 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, @@ -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: workspaceId as string, + folderId: knowledgeBase?.folderId, + }) + const [showTagsModal, setShowTagsModal] = useState(false) /** @@ -490,14 +503,48 @@ export function Document({ [guardDirtyAction, navigateToChunk] ) - const handleNavToKB = useCallback(() => { - router.push(`/workspace/${workspaceId}/knowledge`) - }, [router, workspaceId]) + const handleNavToFolder = useCallback( + (folderId: string | null) => { + router.push(folderedResourceListHref('knowledge_base', workspaceId as string, folderId)) + }, + [router, workspaceId] + ) const handleNavToKBDetail = useCallback(() => { router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`) }, [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) }, [docRename.startRename, documentId, effectiveDocumentName]) @@ -508,24 +555,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 +580,11 @@ export function Document({ ] : []), ], - }, - ], + } + ), [ combinedError, - handleNavToKB, - handleNavToKBDetail, - knowledgeBaseCrumbLabel, + documentTrail, documentCrumbLabel, DocumentIcon, docRename.editingId, @@ -987,58 +1018,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]/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 +1107,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..729fa3e0c35 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[] = [] @@ -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]) } /** From 010608d0bed59ebf624a6496733bebd5ef33687d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 18:14:08 -0700 Subject: [PATCH 2/4] fix(knowledge): confirm before a breadcrumb navigates away from an unsaved chunk --- .../knowledge/[id]/[documentId]/document.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) 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 7f947549b9b..332f5549ef1 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -503,16 +503,36 @@ export function Document({ [guardDirtyAction, navigateToChunk] ) + /** + * 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 — the same reason the in-page transitions go + * through {@link guardDirtyAction}. + * + * Gated on the editor actually being open, not on `isDirty` alone: `UnsavedChangesModal` + * mounts only alongside the editor, and `isDirty` outlives it when the editor is unmounted + * by a URL change rather than by `closeEditor` (browser Back off an edited chunk). Guarding + * unconditionally would then raise a modal nothing renders, leaving the crumb dead. + */ + const guardRouteChange = useCallback( + (navigate: () => void) => { + if (isCreatingNewChunk || selectedChunkId) guardDirtyAction(navigate) + else navigate() + }, + [isCreatingNewChunk, selectedChunkId, guardDirtyAction] + ) + const handleNavToFolder = useCallback( (folderId: string | null) => { - router.push(folderedResourceListHref('knowledge_base', workspaceId as string, folderId)) + guardRouteChange(() => + router.push(folderedResourceListHref('knowledge_base', workspaceId as string, folderId)) + ) }, - [router, workspaceId] + [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 From e409ad9bd58d3fd7600d3253fa241f87eb55324d Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 10 Aug 2026 18:23:51 -0700 Subject: [PATCH 3/4] fix(knowledge): source the pluralized root label from the folder registry --- .../[workspaceId]/knowledge/[id]/[documentId]/loading.tsx | 5 ++++- .../app/workspace/[workspaceId]/knowledge/[id]/loading.tsx | 5 ++++- apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx | 5 ++++- apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx | 5 ++++- 4 files changed, 16 insertions(+), 4 deletions(-) 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]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx index 414729a9d13..5e31ae87167 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/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: 'name', header: 'Name', widthMultiplier: 0.8 }, @@ -25,7 +28,7 @@ const ACTIONS: ChromeActionSpec[] = [ ] const BREADCRUMBS: BreadcrumbItem[] = [ - { label: 'Knowledge bases', icon: Database, onClick: noop }, + { label: KNOWLEDGE_HEADER.rootLabel, icon: Database, onClick: noop }, { label: '…', icon: Database, terminal: true }, ] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx index 234c99420d7..c2de47d0f0b 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/loading.tsx @@ -6,6 +6,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: 'name', header: 'Name' }, @@ -26,7 +29,7 @@ export default function KnowledgeLoading() { return ( Date: Mon, 10 Aug 2026 18:31:10 -0700 Subject: [PATCH 4/4] chore(folders): tighten the shared breadcrumb docs and dedupe the ancestry type --- .../components/folders/folder-breadcrumbs.ts | 42 +++++++------------ .../components/folders/foldered-resources.ts | 7 ++-- .../folders/use-folder-navigation.ts | 40 ++++-------------- .../workspace/[workspaceId]/files/files.tsx | 13 +++--- .../knowledge/[id]/[documentId]/document.tsx | 18 ++++---- .../[workspaceId]/knowledge/knowledge.tsx | 16 ++++--- .../workspace/[workspaceId]/tables/tables.tsx | 2 +- 7 files changed, 52 insertions(+), 86 deletions(-) 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 ba0d7645fc6..7a2b3d88f50 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -7,11 +7,9 @@ import type { } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' /** - * The only thing a breadcrumb trail needs from a folder tree node. Structural rather than - * `WorkflowFolder` so the Files tree builds its trail through this same code path instead of - * forking one: file folders live in the same `folder` table, but they are served by their own - * routes and carry their own row type (see the `servedFolderResourceTypeSchema` docs in - * `@/lib/api/contracts/folders` for why that split exists and is staying). + * 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 @@ -19,18 +17,16 @@ export interface BreadcrumbFolder { parentId: string | null } +const EMPTY_CHAIN: never[] = [] + /** - * Root-first ancestor chain for a breadcrumb trail, that folder last. Empty at the workspace - * root — and empty for a chain that does not reach the root, where {@link folderAncestorChain} - * would hand back the part it walked. - * - * A header falls back to the root title rather than rendering a trail that silently skips a - * level: a partial path is not a shorter path, it is a wrong one, claiming the deepest folder - * it resolved sits at the workspace root. A chain is complete exactly when its first element - * has no parent, which also rejects a cycle (the DB permits one between constraint checks). + * 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. * - * `folderById` must therefore be the complete tree — see `FolderAncestors.foldersResolved`; - * a partially loaded map reads as an orphan and collapses. + * 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, @@ -40,10 +36,8 @@ export function breadcrumbFolderChain( return chain.length === 0 || chain[0].parentId === null ? chain : EMPTY_CHAIN } -const EMPTY_CHAIN: never[] = [] - interface FolderBreadcrumbItemsBase { - /** Root crumb label — the page's own name ("Knowledge Base", "Tables"). */ + /** Root crumb label — the page's own name ("Knowledge bases", "Tables"). */ rootLabel: string rootIcon?: ElementType /** Root-first ancestor chain, from {@link folderAncestorChain}. */ @@ -84,17 +78,13 @@ export type FolderBreadcrumbItemsOptions = const NO_TRAILING_CRUMBS: BreadcrumbItem[] = [] /** - * Converts a folder ancestor chain into the `BreadcrumbItem[]` that `Resource.Header` - * renders, for a list page (`Tables / Reports`) or a detail page (`Tables / Reports / Q3`). + * 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 a list page at the workspace root returns - * 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(options: FolderBreadcrumbItemsOptions): BreadcrumbItem[] { const { rootLabel, rootIcon, breadcrumbs, onNavigate } = options diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts index 5f0cdb2d43f..f370e98762c 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/foldered-resources.ts @@ -25,10 +25,9 @@ export interface FolderedResourceHeaderMeta { /** * The per-resource facts a foldered header needs, in one place. * - * Each of these was previously restated at every surface that renders the resource — the list - * page, the detail page, and (for knowledge bases) the document and chunk views — which is how - * the same trail ends up with a different label or icon depending on which page you reached it - * from. Adding a foldered resource is an entry here plus its pages, never a fifth literal. + * 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, 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 abfe48a6b80..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 @@ -7,38 +7,20 @@ import { folderNavParsers, folderNavUrlKeys, } from '@/app/workspace/[workspaceId]/components/folders/search-params' -import { useFolderAncestors } from '@/app/workspace/[workspaceId]/components/folders/use-folder-ancestors' -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 } /** @@ -59,11 +41,12 @@ export function useFolderNavigation({ folderNavUrlKeys ) - const { ancestors, folders, folderById, foldersResolved } = useFolderAncestors({ + const ancestry = useFolderAncestors({ resourceType, workspaceId, folderId: currentFolderId, }) + const { folderById, foldersResolved } = ancestry const setCurrentFolderId = useCallback( (folderId: string | null) => { @@ -96,12 +79,5 @@ export function useFolderNavigation({ void setFolderParams({ folderId: null }, { history: 'replace' }) }, [foldersResolved, currentFolderId, folderById, setFolderParams]) - return { - currentFolderId, - setCurrentFolderId, - breadcrumbs: ancestors, - 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 94a52066eb7..a79347de310 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1744,15 +1744,12 @@ export function Files() { ) /** - * The trail while a file's content loads. Reuses the open folder from the URL, so arriving - * from a list page inside `A/B` holds `Files / A / B / …` instead of collapsing to - * `Files / …` and jumping back out once the file lands. A cold deep-link carries no - * `?folderId=`, and the file whose folder it would name has not loaded yet, so that case - * still starts at the root. + * 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. * - * Rendered only on the file *detail* route, so its folder crumbs leave this path through - * the router exactly as {@link fileDetailBreadcrumbs} does — the nuqs setter would append - * `?folderId=` to the still-open file's own URL and go nowhere. + * 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[] => 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 332f5549ef1..974a0896848 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx @@ -135,7 +135,7 @@ export function Document({ knowledgeBaseName, documentName, }: DocumentProps) { - const { workspaceId } = useParams() + const workspaceId = useParams().workspaceId as string const router = useRouter() const [ { @@ -154,7 +154,7 @@ export function Document({ /** 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: workspaceId as string, + workspaceId, folderId: knowledgeBase?.folderId, }) @@ -504,14 +504,12 @@ export function Document({ ) /** - * 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 — the same reason the in-page transitions go - * through {@link guardDirtyAction}. + * 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 actually being open, not on `isDirty` alone: `UnsavedChangesModal` - * mounts only alongside the editor, and `isDirty` outlives it when the editor is unmounted - * by a URL change rather than by `closeEditor` (browser Back off an edited chunk). Guarding - * unconditionally would then raise a modal nothing renders, leaving the crumb dead. + * 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) => { @@ -524,7 +522,7 @@ export function Document({ const handleNavToFolder = useCallback( (folderId: string | null) => { guardRouteChange(() => - router.push(folderedResourceListHref('knowledge_base', workspaceId as string, folderId)) + router.push(folderedResourceListHref('knowledge_base', workspaceId, folderId)) ) }, [guardRouteChange, router, workspaceId] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 13bb343913d..4be2d30d5a0 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -203,11 +203,17 @@ export function Knowledge() { const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) - const { currentFolderId, setCurrentFolderId, breadcrumbs, folders, folderById, foldersResolved } = - useFolderNavigation({ - resourceType: FOLDER_RESOURCE_TYPE, - workspaceId, - }) + const { + currentFolderId, + setCurrentFolderId, + ancestors: breadcrumbs, + folders, + folderById, + foldersResolved, + } = useFolderNavigation({ + resourceType: FOLDER_RESOURCE_TYPE, + workspaceId, + }) const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index 729fa3e0c35..7a95bbd3ae8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -134,7 +134,7 @@ export function Tables() { const { currentFolderId, setCurrentFolderId, - breadcrumbs: folderChain, + ancestors: folderChain, folders, folderById, foldersResolved,