fix(folders): show the folder trail in table and knowledge base headers (#6515)

* fix(folders): show the folder trail in table and knowledge base headers

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.

* fix(knowledge): confirm before a breadcrumb navigates away from an unsaved chunk

* fix(knowledge): source the pluralized root label from the folder registry

* chore(folders): tighten the shared breadcrumb docs and dedupe the ancestry type
This commit is contained in:
Waleed
2026-08-10 18:37:46 -07:00
committed by GitHub
parent c5db4033f6
commit fff66068a2
20 changed files with 823 additions and 469 deletions
@@ -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<T extends BreadcrumbFolder>(
folderId: string | null | undefined,
folderById: ReadonlyMap<string, T>
): 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
}
@@ -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)
}
@@ -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<string, WorkflowFolder> = { 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<string, WorkflowFolder> = {
a: makeFolder('a', 'b'),
b: makeFolder('b', 'a'),
}
expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a'])
})
})
@@ -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'
@@ -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 })}`
}
@@ -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<string, WorkflowFolder>
/**
* 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<string, WorkflowFolder>()
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,
}
}
@@ -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<string, WorkflowFolder>
/**
* 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<string, WorkflowFolder>()
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<string>()
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 }
}
@@ -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 = <Folder className='size-[14px]' />
/** 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<string, number>()
@@ -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<string>()
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 (
<Resource>
<Resource.Header icon={FilesIcon} breadcrumbs={loadingBreadcrumbs} />
<Resource.Header icon={FILES_HEADER.rootIcon} breadcrumbs={loadingBreadcrumbs} />
<div className='flex flex-1 items-center justify-center bg-[var(--bg)]'>
<Loader className='size-[20px] text-[var(--text-secondary)]' animate />
</div>
@@ -2052,7 +2043,7 @@ export function Files() {
<FileDocRoomProvider>
<Resource>
<Resource.Header
icon={FilesIcon}
icon={FILES_HEADER.rootIcon}
breadcrumbs={fileDetailBreadcrumbs}
actions={fileActions}
aside={<FileDocAvatars />}
@@ -2119,8 +2110,8 @@ export function Files() {
>
<Resource onContextMenu={handleContentContextMenu}>
<Resource.Header
icon={FilesIcon}
title='Files'
icon={FILES_HEADER.rootIcon}
title={FILES_HEADER.rootLabel}
breadcrumbs={listBreadcrumbs}
actions={headerActionsConfig}
/>
@@ -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 / <base> / <last>`. 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<BreadcrumbItem[]>(
() =>
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<BreadcrumbItem[]>(
() => [
{ 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<BreadcrumbItem[]>(
() => [...editorBreadcrumbBase, { label: 'New Chunk', terminal: true }],
[editorBreadcrumbBase]
() => documentTrail({ label: 'New Chunk', terminal: true }, handleBackAttempt),
[documentTrail, handleBackAttempt]
)
const editChunkBreadcrumbs = useMemo<BreadcrumbItem[]>(
() => [
...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<BreadcrumbItem[]>(
() => [
{ 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(() => {
@@ -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 },
]
@@ -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({
<>
<Resource onContextMenu={handleEmptyContextMenu}>
<Resource.Header
icon={Database}
title='Knowledge bases'
icon={FOLDERED_RESOURCE_HEADERS.knowledge_base.rootIcon}
title={FOLDERED_RESOURCE_HEADERS.knowledge_base.rootLabel}
breadcrumbs={breadcrumbs}
actions={[
...headerActions,
{
text: 'New documents',
icon: Plus,
onSelect: handleAddDocuments,
disabled: userPermissions.canEdit !== true,
variant: 'primary',
},
]}
actions={headerActions}
/>
<Resource.Options
search={{
@@ -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 },
]
@@ -33,6 +33,7 @@ import type {
import {
buildDescendantIndex,
buildMoveOptions,
FOLDERED_RESOURCE_HEADERS,
FolderContextMenu,
folderBreadcrumbItems,
folderRow,
@@ -110,8 +111,8 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [
const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small'
const ROOT_BREADCRUMB_LABEL = 'Knowledge bases'
const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const
const ROOT_BREADCRUMB_LABEL = FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootLabel
function connectorCell(connectorTypes?: string[]): ResourceCell {
if (!connectorTypes || connectorTypes.length === 0) {
@@ -202,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()
@@ -906,7 +913,7 @@ export function Knowledge() {
() =>
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() {
<>
<Resource onContextMenu={handleContentContextMenu}>
<Resource.Header
icon={Database}
icon={FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootIcon}
title={ROOT_BREADCRUMB_LABEL}
breadcrumbs={listBreadcrumbs}
actions={headerActions}
@@ -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 (
<ResourceChromeFallback
icon={Database}
title='Knowledge bases'
title={KNOWLEDGE_HEADER.rootLabel}
columns={COLUMNS}
actions={ACTIONS}
searchPlaceholder='Search knowledge bases...'
@@ -2,12 +2,15 @@ import { Suspense } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import type { Metadata } from 'next'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { FOLDERED_RESOURCE_HEADERS } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources'
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
import KnowledgeLoading from '@/app/workspace/[workspaceId]/knowledge/loading'
import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch'
const KNOWLEDGE_HEADER = FOLDERED_RESOURCE_HEADERS.knowledge_base
export const metadata: Metadata = {
title: 'Knowledge bases',
title: KNOWLEDGE_HEADER.rootLabel,
}
/**
@@ -11,6 +11,7 @@ import { canMutateWorkspaceSettingsSection } from '@/components/settings/navigat
import type { ServedFolderResourceType } from '@/lib/api/contracts/folders'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { type ColumnOption, SortDropdown } from '@/app/workspace/[workspaceId]/components'
import { folderedResourceListHref } from '@/app/workspace/[workspaceId]/components/folders'
import { RESOURCE_REGISTRY } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry'
import type { MothershipResourceType } from '@/app/workspace/[workspaceId]/home/types'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -71,11 +72,11 @@ function getResourceHref(
case 'folder':
return `${base}/w`
case 'workspace_folder':
return `${base}/files?folderId=${id}`
return folderedResourceListHref('file', workspaceId, id)
case 'knowledge_folder':
return `${base}/knowledge?folderId=${id}`
return folderedResourceListHref('knowledge_base', workspaceId, id)
case 'table_folder':
return `${base}/tables?folderId=${id}`
return folderedResourceListHref('table', workspaceId, id)
case 'chat':
return `${base}/chat/${id}`
}
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { Chip, ChipConfirmModal, toast } from '@sim/emcn'
import { Download, Lock, Pencil, Table as TableIcon, Trash, Upload } from '@sim/emcn/icons'
import { Download, Lock, Pencil, Trash, Upload } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { useParams, useRouter } from 'next/navigation'
@@ -28,6 +28,12 @@ import {
Resource,
type SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import {
FOLDERED_RESOURCE_HEADERS,
folderBreadcrumbItems,
folderedResourceListHref,
useFolderAncestors,
} from '@/app/workspace/[workspaceId]/components/folders'
import { PresenceAvatars } from '@/app/workspace/[workspaceId]/components/presence/presence-avatars'
import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
@@ -1002,9 +1008,24 @@ export function Table({
},
})
const handleNavigateBack = useCallback(() => {
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({
<Resource>
{!embedded && (
<Resource.Header
icon={TableIcon}
icon={FOLDERED_RESOURCE_HEADERS.table.rootIcon}
breadcrumbs={breadcrumbs}
aside={
<div className='flex items-center gap-1.5'>
@@ -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() {
<>
<Resource onContextMenu={handleContentContextMenu}>
<Resource.Header
icon={TableIcon}
icon={FOLDERED_RESOURCE_HEADERS.table.rootIcon}
title={ROOT_LABEL}
breadcrumbs={breadcrumbs}
actions={headerActions}
+6 -15
View File
@@ -1,9 +1,14 @@
import { folderAncestorChain } from '@/lib/folders/tree'
import type { WorkflowFolder } from '@/stores/folders/types'
/**
* Returns true when the folder or one of its ancestors is locked. Used to
* mirror server-side cascading folder lock policy on the client without an
* extra round-trip.
*
* Walks inline rather than through {@link folderAncestorChain} on purpose: this runs per row
* on every sidebar render, and stopping at the first locked ancestor beats materializing the
* whole chain to scan it. Same for {@link findLockedAncestorFolder} below.
*/
export function isFolderOrAncestorLocked(
folderId: string | null | undefined,
@@ -36,21 +41,7 @@ export function getFolderPath(
folders: Record<string, WorkflowFolder>,
separator = ' / '
): string | null {
if (!folderId) return null
const segments: string[] = []
const visited = new Set<string>()
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
}
+41 -19
View File
@@ -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.
*
* `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.
* `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.
*
* 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 folderAncestorChain<T extends FolderAncestorNode>(
folderId: string | null | undefined,
lookup: (id: string) => T | undefined
): T[] {
const chain: T[] = []
const visited = new Set<string>()
let currentId: string | null | undefined = folderId
while (currentId && !visited.has(currentId)) {
visited.add(currentId)
const folder = lookup(currentId)
if (!folder) break
chain.unshift(folder)
currentId = folder.parentId
}
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<string, WorkflowFolder>,
folderId: string
): WorkflowFolder[] {
const path: WorkflowFolder[] = []
const visited = new Set<string>()
let currentId: string | null = folderId
while (currentId && folders[currentId] && !visited.has(currentId)) {
visited.add(currentId)
const folder: WorkflowFolder = folders[currentId]
path.unshift(folder)
currentId = folder.parentId
}
return path
return folderAncestorChain(folderId, (id) => folders[id])
}
/**