From fcb9c4a44932dec4b19a8faaf5a0c22c56e9d9a5 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 19:16:12 -0700 Subject: [PATCH 01/11] feat(resources): multiselect on tables and knowledge, spring-loaded folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tables and Knowledge lists get the checkbox multiselect Files already had — selection, shift-click ranges, select-all, and a shared bulk action bar for move and delete. Dragging a resource onto a folder row and resting there now opens that folder, so nested filing is one gesture (macOS Finder spring-loading). Works on Files, Tables, and Knowledge. Selection, the action bar, the drag payload, the drag ghost, and drag teardown are extracted to shared modules; Files migrates onto them rather than keeping its own copies. Bulk move and delete land as single authorized operations that take folders and resources together, so a mixed selection commits once instead of fanning out. Fixes two latent UI bugs: the drop-target outline referenced --accent, an HSL-channel token only valid via hsl(), so it silently rendered as currentColor; and rows painted hover and selected with the same surface token, making the two states indistinguishable. --- .../app/api/knowledge/bulk-delete/route.ts | 30 ++ apps/sim/app/api/knowledge/bulk-move/route.ts | 31 ++ apps/sim/app/api/table/bulk-delete/route.ts | 29 ++ apps/sim/app/api/table/bulk-move/route.ts | 30 ++ .../components/folders/drag-payload.ts | 41 ++ .../folders/folder-context-menu.tsx | 5 +- .../components/folders/folder-row-id.ts | 20 + .../[workspaceId]/components/folders/index.ts | 17 +- .../components/folders/move-options.tsx | 47 +- .../components/folders/use-drag-teardown.ts | 33 ++ .../folders/use-folder-navigation.ts | 11 +- .../folders/use-folder-row-drag-drop.ts | 233 ++++++---- .../components/folders/use-row-drag-ghost.ts | 58 +++ .../folders/use-spring-loaded-folder.test.tsx | 200 +++++++++ .../folders/use-spring-loaded-folder.ts | 119 +++++ .../[workspaceId]/components/index.ts | 13 +- .../components/resource/bulk-outcome.ts | 53 +++ .../components/action-bar/action-bar.tsx | 146 ++++++ .../resource/components/action-bar/index.ts | 2 + .../resource/components/owner-cell/index.ts | 3 +- .../components/owner-cell/owner-cell.tsx | 9 +- .../components/resource-options/index.ts | 6 +- .../resource-options/resource-options.tsx | 17 +- .../components/resource/resource.tsx | 18 +- .../components/resource/selection-label.ts | 9 + .../use-resource-row-selection.test.tsx | 173 +++++++ .../resource/use-resource-row-selection.ts | 200 +++++++++ .../components/action-bar/action-bar.tsx | 126 ------ .../files/components/action-bar/index.ts | 1 - .../workspace/[workspaceId]/files/files.tsx | 385 +++++----------- .../[workspaceId]/knowledge/[id]/base.tsx | 8 +- .../[workspaceId]/knowledge/knowledge.tsx | 301 ++++++++++--- .../workspace/[workspaceId]/tables/tables.tsx | 272 +++++++++-- apps/sim/hooks/queries/kb/knowledge.ts | 85 ++++ apps/sim/hooks/queries/tables.ts | 83 ++++ apps/sim/lib/api/contracts/knowledge/base.ts | 130 ++++++ apps/sim/lib/api/contracts/tables.ts | 123 +++++ .../tools/server/knowledge/knowledge-base.ts | 6 +- apps/sim/lib/core/application/batch-policy.ts | 61 +++ .../lib/core/application/bulk-items.test.ts | 79 ++++ apps/sim/lib/core/application/bulk-items.ts | 58 +++ apps/sim/lib/folders/bulk.ts | 277 ++++++++++++ apps/sim/lib/folders/orchestration.ts | 35 +- apps/sim/lib/folders/subtree.test.ts | 50 ++- apps/sim/lib/folders/subtree.ts | 44 +- apps/sim/lib/knowledge/api/route-policies.ts | 7 + .../lib/knowledge/application/batch-policy.ts | 60 ++- .../lib/knowledge/application/bulk.test.ts | 306 +++++++++++++ apps/sim/lib/knowledge/application/bulk.ts | 395 ++++++++++++++++ .../knowledge/application/knowledge-bases.ts | 17 +- .../knowledge/application/operations.test.ts | 2 + .../lib/knowledge/application/operations.ts | 12 + apps/sim/lib/knowledge/constants.ts | 7 + apps/sim/lib/table/api/route-policies.ts | 8 + .../sim/lib/table/application/batch-policy.ts | 57 +++ apps/sim/lib/table/application/bulk.test.ts | 379 ++++++++++++++++ apps/sim/lib/table/application/bulk.ts | 422 ++++++++++++++++++ apps/sim/lib/table/application/operations.ts | 2 + apps/sim/lib/table/constants.ts | 7 + apps/sim/lib/table/service.ts | 12 +- scripts/check-api-validation-contracts.ts | 4 +- ...check-tool-registry-boundary.baseline.json | 14 +- 62 files changed, 4752 insertions(+), 636 deletions(-) create mode 100644 apps/sim/app/api/knowledge/bulk-delete/route.ts create mode 100644 apps/sim/app/api/knowledge/bulk-move/route.ts create mode 100644 apps/sim/app/api/table/bulk-delete/route.ts create mode 100644 apps/sim/app/api/table/bulk-move/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts create mode 100644 apps/sim/lib/core/application/batch-policy.ts create mode 100644 apps/sim/lib/core/application/bulk-items.test.ts create mode 100644 apps/sim/lib/core/application/bulk-items.ts create mode 100644 apps/sim/lib/folders/bulk.ts create mode 100644 apps/sim/lib/knowledge/application/bulk.test.ts create mode 100644 apps/sim/lib/knowledge/application/bulk.ts create mode 100644 apps/sim/lib/table/application/batch-policy.ts create mode 100644 apps/sim/lib/table/application/bulk.test.ts create mode 100644 apps/sim/lib/table/application/bulk.ts diff --git a/apps/sim/app/api/knowledge/bulk-delete/route.ts b/apps/sim/app/api/knowledge/bulk-delete/route.ts new file mode 100644 index 00000000000..46632c54452 --- /dev/null +++ b/apps/sim/app/api/knowledge/bulk-delete/route.ts @@ -0,0 +1,30 @@ +import { bulkDeleteKnowledgeItemsContract } from '@/lib/api/contracts/knowledge' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { bulkDeleteKnowledgeItems } from '@/lib/knowledge/application/bulk' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: bulkDeleteKnowledgeItemsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.bulkDeleteItems, + rateLimit: internalRateLimits.none({ + reason: 'Matches the unlimited single-base and single-folder deletes it batches', + }), + errorPolicy: internalKnowledgeErrorPolicies.bulkDelete, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + knowledgeBaseIds: body.knowledgeBaseIds, + folderIds: body.folderIds, + source: 'ui', + }), + useCase: bulkDeleteKnowledgeItems, + present: ({ deleted, skipped, notFound, failed, deletedItems }) => ({ + success: true as const, + data: { deleted, skipped, notFound, failed, deletedItems }, + }), +}) diff --git a/apps/sim/app/api/knowledge/bulk-move/route.ts b/apps/sim/app/api/knowledge/bulk-move/route.ts new file mode 100644 index 00000000000..c06a5f9026e --- /dev/null +++ b/apps/sim/app/api/knowledge/bulk-move/route.ts @@ -0,0 +1,31 @@ +import { bulkMoveKnowledgeItemsContract } from '@/lib/api/contracts/knowledge' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { bulkMoveKnowledgeItems } from '@/lib/knowledge/application/bulk' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: bulkMoveKnowledgeItemsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.bulkMoveItems, + rateLimit: internalRateLimits.none({ + reason: 'Matches the unlimited single-base and single-folder moves it batches', + }), + errorPolicy: internalKnowledgeErrorPolicies.bulkMove, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + knowledgeBaseIds: body.knowledgeBaseIds, + folderIds: body.folderIds, + targetFolderId: body.targetFolderId, + source: 'ui', + }), + useCase: bulkMoveKnowledgeItems, + present: ({ moved, skipped, notFound, failed }) => ({ + success: true as const, + data: { moved, skipped, notFound, failed }, + }), +}) diff --git a/apps/sim/app/api/table/bulk-delete/route.ts b/apps/sim/app/api/table/bulk-delete/route.ts new file mode 100644 index 00000000000..8af204a51a8 --- /dev/null +++ b/apps/sim/app/api/table/bulk-delete/route.ts @@ -0,0 +1,29 @@ +import { bulkDeleteTablesContract } from '@/lib/api/contracts/tables' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalTableErrorPolicies } from '@/lib/table/api' +import { bulkDeleteTables } from '@/lib/table/application/bulk' +import { tableOperations } from '@/lib/table/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: bulkDeleteTablesContract, + auth: internalSessionAuth, + operation: tableOperations.bulkDelete, + rateLimit: internalRateLimits.none({ + reason: 'Matches the unlimited single-table and single-folder deletes it batches', + }), + errorPolicy: internalTableErrorPolicies.bulk, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + tableIds: body.tableIds, + folderIds: body.folderIds, + }), + useCase: bulkDeleteTables, + present: ({ deleted, skipped, notFound, failed, deletedItems }) => ({ + success: true as const, + data: { deleted, skipped, notFound, failed, deletedItems }, + }), +}) diff --git a/apps/sim/app/api/table/bulk-move/route.ts b/apps/sim/app/api/table/bulk-move/route.ts new file mode 100644 index 00000000000..1e8ac395fff --- /dev/null +++ b/apps/sim/app/api/table/bulk-move/route.ts @@ -0,0 +1,30 @@ +import { bulkMoveTablesContract } from '@/lib/api/contracts/tables' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalTableErrorPolicies } from '@/lib/table/api' +import { bulkMoveTables } from '@/lib/table/application/bulk' +import { tableOperations } from '@/lib/table/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: bulkMoveTablesContract, + auth: internalSessionAuth, + operation: tableOperations.bulkMove, + rateLimit: internalRateLimits.none({ + reason: 'Matches the unlimited single-table and single-folder moves it batches', + }), + errorPolicy: internalTableErrorPolicies.bulk, + mapInput: ({ body }) => ({ + assertedWorkspaceId: body.workspaceId, + tableIds: body.tableIds, + folderIds: body.folderIds, + targetFolderId: body.targetFolderId, + }), + useCase: bulkMoveTables, + present: ({ moved, skipped, notFound, failed }) => ({ + success: true as const, + data: { moved, skipped, notFound, failed }, + }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts new file mode 100644 index 00000000000..227c17308fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts @@ -0,0 +1,41 @@ +/** + * The row ids a drag carries, written to and read from `dataTransfer` as JSON under a + * private MIME type. + * + * The payload has to live on the event rather than only in component state: a drag survives + * the source row unmounting — spring-loading navigates away mid-drag — and it can be released + * over a different mount of the same page. + * + * Each surface passes its own MIME so a drag from one list is never mistaken for a drag from + * another, and so an unrelated OS drag is ignored outright. + */ + +/** Writes `rowIds` under `mime`, plus a plain-text fallback for drops outside the app. */ +export function writeRowDragPayload( + dataTransfer: DataTransfer, + mime: string, + rowIds: string[] +): void { + dataTransfer.setData(mime, JSON.stringify(rowIds)) + dataTransfer.setData('text/plain', rowIds.join(',')) +} + +/** + * Reads the row ids back, returning `null` when the payload is absent (a foreign drag) or + * malformed (another writer on the same MIME) rather than throwing mid-drop. Callers fall back + * to their in-memory source for drags that never round-tripped through `dataTransfer`. + */ +export function readRowDragPayload(dataTransfer: DataTransfer, mime: string): string[] | null { + const raw = dataTransfer.getData(mime) + if (!raw) return null + try { + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return null + const rowIds = parsed.filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ) + return rowIds.length > 0 ? rowIds : null + } catch { + return null + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx index 614786a1ed3..41b8682aef6 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-context-menu.tsx @@ -35,9 +35,8 @@ interface FolderContextMenuProps { * Row context menu for a folder, shared by the resource lists built on the generic folder * engine — Knowledge and Tables — so a folder offers the same actions on both. * - * Files is deliberately not a consumer: its rows carry multi-select and bulk actions, so a - * folder there routes through `FileRowContextMenu` alongside the file rows it is selected - * with. Converging the two is follow-up work. + * Files is deliberately not a consumer: a folder there routes through `FileRowContextMenu` + * alongside the file rows it is selected with. Converging the two is follow-up work. * * Mirrors the resource-row menus (`KnowledgeBaseContextMenu`, `FileRowContextMenu`): a * `DropdownMenu` anchored to a one-pixel fixed trigger at the cursor, non-modal so the list diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts index f4561a3adef..82042473d51 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-row-id.ts @@ -26,3 +26,23 @@ export function parseFolderedRowId(rowId: string): ParsedFolderedRowId { } return { kind: 'resource', id: rowId } } + +/** + * Splits a selection of foldered row ids into the two id lists every bulk operation takes. + * + * A foldered list holds folder rows and resource rows in one selection (see + * {@link folderRowId}), so every consumer needs this same split before it can call an API. + */ +export function splitFolderedRowIds(rowIds: Iterable): { + folderIds: string[] + resourceIds: string[] +} { + const folderIds: string[] = [] + const resourceIds: string[] = [] + for (const rowId of rowIds) { + const parsed = parseFolderedRowId(rowId) + if (parsed.kind === 'folder') folderIds.push(parsed.id) + else resourceIds.push(parsed.id) + } + return { folderIds, resourceIds } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts index aee06919742..e788e8686d9 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/index.ts @@ -1,3 +1,4 @@ +export { readRowDragPayload, writeRowDragPayload } from './drag-payload' export type { BreadcrumbFolder, FolderBreadcrumbItemsOptions } from './folder-breadcrumbs' export { breadcrumbFolderChain, folderBreadcrumbItems } from './folder-breadcrumbs' export { FolderContextMenu } from './folder-context-menu' @@ -5,16 +6,17 @@ 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 { folderRowId, parseFolderedRowId, splitFolderedRowIds } 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 type { BuildMoveOptionsParams, MoveOptionFolder, MoveOptionNode } from './move-options' export { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, renderMoveOption, @@ -23,9 +25,18 @@ export { export type { SortableResource } from './resource-sort' export { sortResources } from './resource-sort' export { folderNavParsers, folderNavUrlKeys } from './search-params' +export { useDragTeardown } from './use-drag-teardown' 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' +export type { FolderedRowMove, UseFolderRowDragDropOptions } from './use-folder-row-drag-drop' export { useFolderRowDragDrop } from './use-folder-row-drag-drop' +export type { RowDragGhost } from './use-row-drag-ghost' +export { useRowDragGhost } from './use-row-drag-ghost' +export type { + SpringLoadedFolder, + SpringOpenOptions, + UseSpringLoadedFolderOptions, +} from './use-spring-loaded-folder' +export { SPRING_LOAD_DELAY_MS, useSpringLoadedFolder } from './use-spring-loaded-folder' diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx index 66f0b2e455a..18a24b0ee79 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx @@ -7,7 +7,6 @@ import { DropdownMenuSubTrigger, } from '@sim/emcn' import { Folder } from '@sim/emcn/icons' -import type { WorkflowFolder } from '@/stores/folders/types' export interface MoveOptionNode { value: string @@ -26,8 +25,16 @@ export function parseMoveOptionValue(optionValue: string): string | null { return optionValue === ROOT_MOVE_OPTION_VALUE ? null : optionValue } +/** The folder fields the move-option builders actually read, so any folder tree can use them. */ +export interface MoveOptionFolder { + id: string + name: string + parentId: string | null + sortOrder: number +} + export interface BuildMoveOptionsParams { - folders: WorkflowFolder[] + folders: readonly MoveOptionFolder[] rootLabel: string /** * Folder ids that must not appear as destinations — the folder being moved and every @@ -53,7 +60,7 @@ export function buildMoveOptions({ rootLabel, excludedFolderIds, }: BuildMoveOptionsParams): MoveOptionNode[] { - const childrenByParent = new Map() + const childrenByParent = new Map() for (const folder of folders) { if (excludedFolderIds?.has(folder.id)) continue const parentId = folder.parentId ?? null @@ -80,7 +87,9 @@ export function buildMoveOptions({ * candidate instead of re-walking the tree. `seen` terminates a cycle, which the DB permits * between constraint checks. */ -export function buildDescendantIndex(folders: WorkflowFolder[]): Map> { +export function buildDescendantIndex( + folders: readonly { id: string; parentId: string | null }[] +): Map> { const childrenByParent = new Map() for (const folder of folders) { if (!folder.parentId) continue @@ -167,3 +176,33 @@ export function renderMoveOptions( ) } + +/** + * Move destinations for a selection, with every selected folder and its subtree excluded — a + * folder cannot be filed into itself or anything beneath it. + * + * Shared because that exclusion is a correctness invariant, not a preference: hand-copying it + * per surface is how one list eventually offers a cyclic destination. Covers the single-folder + * case too — pass a one-element array. + */ +export function buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel, + excludeFolderIds, + descendantsByFolderId, +}: { + folders: readonly MoveOptionFolder[] + rootLabel: string + excludeFolderIds: readonly string[] + descendantsByFolderId: Map> +}): MoveOptionNode[] { + if (excludeFolderIds.length === 0) return buildMoveOptions({ folders, rootLabel }) + + const excludedFolderIds = new Set(excludeFolderIds) + for (const folderId of excludeFolderIds) { + for (const descendantId of descendantsByFolderId.get(folderId) ?? []) { + excludedFolderIds.add(descendantId) + } + } + return buildMoveOptions({ folders, rootLabel, excludedFolderIds }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts new file mode 100644 index 00000000000..7860a0b0ab7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts @@ -0,0 +1,33 @@ +'use client' + +import { useEffect, useRef } from 'react' + +/** + * Runs a drag's teardown wherever the drag actually ends. + * + * `dragend` fires on the SOURCE ROW, which is not guaranteed to still exist: spring-loading + * navigates into another folder mid-drag, which unmounts it. A row-level handler would then + * never run, leaving the drag ghost stuck on the page, every row frozen at drag opacity, and a + * stale source id that makes the next drop resolve against rows the user never picked up. + * + * Listening on `window` catches the event wherever it lands — including a drag cancelled with + * Escape or released outside the window, which never reaches a row at all. + * + * `teardown` is read through a ref and the listeners bind once, deliberately. Depending on the + * callback would re-run this effect on every render, and any cleanup wired into it would then + * abort drags that are still in progress — a bug this exact hook already shipped once. + */ +export function useDragTeardown(teardown: () => void): void { + const teardownRef = useRef(teardown) + teardownRef.current = teardown + + useEffect(() => { + const handleDragEnd = () => teardownRef.current() + window.addEventListener('dragend', handleDragEnd) + window.addEventListener('drop', handleDragEnd) + return () => { + window.removeEventListener('dragend', handleDragEnd) + window.removeEventListener('drop', handleDragEnd) + } + }, []) +} 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 19eb8f3e7d2..a739111c83a 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 @@ -20,7 +20,12 @@ export interface UseFolderNavigationOptions { export interface FolderNavigation extends FolderAncestors { /** The open folder, or `null` at the workspace root. */ currentFolderId: string | null - setCurrentFolderId: (folderId: string | null) => void + /** + * Opens a folder. Defaults to the param group's `history: 'push'` — a folder the user chose + * to open is a destination. Pass `{ history: 'replace' }` for a write that is not a chosen + * navigation, such as the second and later spring-opens within a single drag. + */ + setCurrentFolderId: (folderId: string | null, options?: { history?: 'push' | 'replace' }) => void } /** @@ -49,8 +54,8 @@ export function useFolderNavigation({ const { folderById, foldersResolved } = ancestry const setCurrentFolderId = useCallback( - (folderId: string | null) => { - void setFolderParams({ folderId }) + (folderId: string | null, options?: { history?: 'push' | 'replace' }) => { + void setFolderParams({ folderId }, options) }, [setFolderParams] ) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index b6a746b2606..c6434958047 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -1,21 +1,34 @@ 'use client' -import { type DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react' +import { + readRowDragPayload, + writeRowDragPayload, +} from '@/app/workspace/[workspaceId]/components/folders/drag-payload' import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' +import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' +import { useRowDragGhost } from '@/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost' +import { + type SpringOpenOptions, + useSpringLoadedFolder, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' import type { RowDragDropConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' -/** - * Private drag payload, namespaced so a drag started on another Sim surface (or an external - * drag) is never mistaken for a foldered list row. - */ +/** The foldered-list drag MIME — see {@link writeRowDragPayload} for why each surface owns one. */ const DRAG_ROW_MIME = 'application/x-sim-foldered-row' -const DRAG_GHOST_STYLE = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' +/** Stands in for an absent spring-open callback, so the timer hook needs no null handling. */ +function noop() {} /** Shared empty set so an idle drag state keeps a stable identity across renders. */ const EMPTY_ROW_IDS = new Set() +/** Rows carried by one drag, already split by kind and stripped of no-op moves. */ +export interface FolderedRowMove { + folderIds: string[] + resourceIds: string[] +} + export interface UseFolderRowDragDropOptions { /** Drag and drop are edits; a reader gets neither draggable rows nor drop targets. */ canEdit: boolean @@ -29,10 +42,30 @@ export interface UseFolderRowDragDropOptions { getResourceFolderId: (resourceId: string) => string | null | undefined /** Label shown in the drag ghost. */ getRowLabel: (rowId: string) => string - /** Reparents a folder into `targetFolderId`. */ - onMoveFolder: (folderId: string, targetFolderId: string) => void - /** Files a resource into `targetFolderId`. */ - onMoveResource: (resourceId: string, targetFolderId: string) => void + /** + * Moves every row of the drag into `targetFolderId` in one call. Rows already sitting + * directly in the target are filtered out before this fires, and it is never called with + * both lists empty — so the consumer maps it straight onto its bulk-move operations. + */ + onMoveRows: (rows: FolderedRowMove, targetFolderId: string) => void + /** + * Checkbox selection, when the list has one. Dragging a selected row carries the whole + * selection; dragging an unselected row collapses the selection onto it first, matching + * every file manager. Omit on a list without selection to keep drags single-row. + */ + selection?: { + selectedRowIds: Set + /** Row ids in display order, so the drag carries them in the order they are read. */ + visibleRowIds: string[] + /** Collapses the selection onto a single row dragged from outside it. */ + replaceSelection: (rowIds: string[]) => void + } + /** + * Opens a folder the drag has rested on, so the user can file into a nested folder without + * dropping first. Forward `options` to the folder-navigation setter so one drag leaves one + * back-stack entry. Omit to disable spring-loading. See {@link useSpringLoadedFolder}. + */ + onSpringOpenFolder?: (folderId: string, options: SpringOpenOptions) => void } /** @@ -40,9 +73,9 @@ export interface UseFolderRowDragDropOptions { * Tables behave exactly like Files: only folder rows accept a drop, a folder cannot land in * itself or its own subtree, and a row already sitting directly in the target is a no-op. * - * Single-row only, which is what the resource lists that use it support. The Files page - * keeps its own configuration because it additionally drags multi-selections and accepts - * external OS file drops. + * Carries a whole checkbox selection when `selection` is supplied, and a single row otherwise. + * The Files page keeps its own configuration because it additionally accepts external OS file + * drops, which need a second drag protocol this hook deliberately does not know about. */ export function useFolderRowDragDrop({ canEdit, @@ -51,8 +84,9 @@ export function useFolderRowDragDrop({ getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, + onSpringOpenFolder, }: UseFolderRowDragDropOptions): RowDragDropConfig { const [activeDropTargetId, setActiveDropTargetId] = useState(null) const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_ROW_IDS) @@ -61,53 +95,74 @@ export function useFolderRowDragDrop({ * faster than a re-render and must decide drop validity against the current source * synchronously. */ - const draggedRowIdRef = useRef(null) - const dragGhostRef = useRef(null) + const draggedRowIdsRef = useRef([]) const optionsRef = useRef({ descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, }) optionsRef.current = { descendantsByFolderId, getFolderParentId, getResourceFolderId, getRowLabel, - onMoveFolder, - onMoveResource, + onMoveRows, + selection, } + const springLoad = useSpringLoadedFolder({ onSpringOpen: onSpringOpenFolder ?? noop }) + + const dragGhost = useRowDragGhost() + + /** Returns the list to its resting state once a drag is over, however it ended. */ + const endDrag = useCallback(() => { + dragGhost.remove() + draggedRowIdsRef.current = [] + springLoad.reset() + setDraggedRowIds(EMPTY_ROW_IDS) + setActiveDropTargetId(null) + }, [dragGhost, springLoad]) + + useDragTeardown(endDrag) + /** - * The ghost lives on `document.body`, but the only thing that removes it is `dragend`, which - * fires on the SOURCE ROW. `Resource.Table` is virtualized, so scrolling the source out of - * view mid-drag unmounts that row and the event never arrives — leaving the ghost stuck on - * the page and every row frozen at drag opacity. Clean up on unmount as the backstop. + * Splits the drag into the rows that would actually move, dropping any row already sitting + * directly in the target. Returns `null` when the drop is illegal outright — the target is + * not a folder, or it is one of the dragged folders or inside one, which would orphan a + * subtree into itself. */ - useEffect( - () => () => { - dragGhostRef.current?.remove() - dragGhostRef.current = null + const resolveMove = useCallback( + (targetRowId: string, sourceRowIds: string[]): FolderedRowMove | null => { + const target = parseFolderedRowId(targetRowId) + if (target.kind !== 'folder') return null + + const { descendantsByFolderId, getFolderParentId, getResourceFolderId } = optionsRef.current + const folderIds: string[] = [] + const resourceIds: string[] = [] + + for (const sourceRowId of sourceRowIds) { + const source = parseFolderedRowId(sourceRowId) + if (source.kind === 'folder') { + if (source.id === target.id) return null + if (descendantsByFolderId.get(source.id)?.has(target.id)) return null + if ((getFolderParentId(source.id) ?? null) === target.id) continue + folderIds.push(source.id) + continue + } + if ((getResourceFolderId(source.id) ?? null) === target.id) continue + resourceIds.push(source.id) + } + + if (folderIds.length === 0 && resourceIds.length === 0) return null + return { folderIds, resourceIds } }, [] ) - const isInvalidDropTarget = useCallback((targetRowId: string, sourceRowId: string) => { - const target = parseFolderedRowId(targetRowId) - if (target.kind !== 'folder') return true - - const source = parseFolderedRowId(sourceRowId) - if (source.kind === 'folder') { - if (source.id === target.id) return true - if (optionsRef.current.descendantsByFolderId.get(source.id)?.has(target.id)) return true - return (optionsRef.current.getFolderParentId(source.id) ?? null) === target.id - } - return (optionsRef.current.getResourceFolderId(source.id) ?? null) === target.id - }, []) - return useMemo( () => ({ activeDropTargetId, @@ -121,29 +176,30 @@ export function useFolderRowDragDrop({ return } - draggedRowIdRef.current = rowId - setDraggedRowIds(new Set([rowId])) + const { selection } = optionsRef.current + /** + * Read the selection in display order rather than insertion order, so a shift-range + * drag carries its rows the way the user sees them. + */ + const sourceRowIds = selection?.selectedRowIds.has(rowId) + ? selection.visibleRowIds.filter((visibleRowId) => + selection.selectedRowIds.has(visibleRowId) + ) + : [rowId] + if (selection && !selection.selectedRowIds.has(rowId)) selection.replaceSelection([rowId]) + + draggedRowIdsRef.current = sourceRowIds + setDraggedRowIds(new Set(sourceRowIds)) e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData(DRAG_ROW_MIME, rowId) - e.dataTransfer.setData('text/plain', rowId) - - const ghost = document.createElement('div') - ghost.style.cssText = DRAG_GHOST_STYLE - const text = document.createElement('span') - text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' - text.textContent = optionsRef.current.getRowLabel(rowId) - ghost.appendChild(text) - document.body.appendChild(ghost) - // Force a layout pass so the drag image is measurable before it is captured. - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost + writeRowDragPayload(e.dataTransfer, DRAG_ROW_MIME, sourceRowIds) + + dragGhost.attach(e, optionsRef.current.getRowLabel(sourceRowIds[0]), sourceRowIds.length) }, onDragOver: (e: DragEvent, rowId) => { - const sourceRowId = draggedRowIdRef.current - if (sourceRowId) { - if (isInvalidDropTarget(rowId, sourceRowId)) return + const sourceRowIds = draggedRowIdsRef.current + if (sourceRowIds.length > 0) { + if (!resolveMove(rowId, sourceRowIds)) return } else if (!e.dataTransfer.types.includes(DRAG_ROW_MIME)) { /** * No local source and no payload of ours — an external or foreign drag. Returning @@ -165,38 +221,55 @@ export function useFolderRowDragDrop({ * would light up as a valid target — including the dragged folder itself and its own * descendants — and the drop would then silently do nothing. */ - if (sourceRowId) setActiveDropTargetId(rowId) + if (sourceRowIds.length > 0) { + setActiveDropTargetId(rowId) + /** + * Armed on the same condition as the highlight, so a folder only springs open where a + * drop was already possible. A folder the drag cannot legally enter never opens. + */ + springLoad.arm(parseFolderedRowId(rowId).id) + } }, onDragLeave: (e: DragEvent, rowId) => { const relatedTarget = e.relatedTarget if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + springLoad.disarm() setActiveDropTargetId((current) => (current === rowId ? null : current)) }, onDrop: (e: DragEvent, rowId) => { e.preventDefault() e.stopPropagation() - setActiveDropTargetId(null) const target = parseFolderedRowId(rowId) - if (target.kind !== 'folder') return - // Prefer the dataTransfer payload over the ref so a drag that started in another - // mount of this page still resolves to a real row id. - const sourceRowId = e.dataTransfer.getData(DRAG_ROW_MIME) || draggedRowIdRef.current - if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return + // mount of this page still resolves to real row ids. + const sourceRowIds = + readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current + const move = + target.kind === 'folder' && sourceRowIds.length > 0 + ? resolveMove(rowId, sourceRowIds) + : null - const source = parseFolderedRowId(sourceRowId) - if (source.kind === 'folder') optionsRef.current.onMoveFolder(source.id, target.id) - else optionsRef.current.onMoveResource(source.id, target.id) - }, - onDragEnd: () => { - dragGhostRef.current?.remove() - dragGhostRef.current = null - draggedRowIdRef.current = null - setDraggedRowIds(EMPTY_ROW_IDS) - setActiveDropTargetId(null) + /** + * Ends the drag here rather than leaving it to `dragend`. This handler stops + * propagation, so the window-level backstop never sees this drop, and the source row + * may already have unmounted — after a spring-open it always has. + */ + endDrag() + + if (move) optionsRef.current.onMoveRows(move, target.id) }, + onDragEnd: endDrag, }), - [activeDropTargetId, draggedRowIds, canEdit, editingRowId, isInvalidDropTarget] + [ + activeDropTargetId, + draggedRowIds, + canEdit, + editingRowId, + resolveMove, + springLoad, + endDrag, + dragGhost, + ] ) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts new file mode 100644 index 00000000000..4cbd12b077f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost.ts @@ -0,0 +1,58 @@ +'use client' + +import type { DragEvent } from 'react' +import { useCallback, useEffect, useRef } from 'react' + +/** + * Inline chrome for the drag image. It is set on a detached DOM node handed to `setDragImage`, + * so it cannot be a Tailwind class list. + * + * `font-family` is deliberately absent: the node is appended to ``, so omitting it lets + * the label inherit the app font and match the row it was lifted from. + */ +const DRAG_GHOST_STYLE = + 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' + +const DRAG_GHOST_LABEL_STYLE = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' + +export interface RowDragGhost { + /** Builds the drag image for this drag and attaches it to the event. */ + attach: (e: DragEvent, label: string, count: number) => void + /** Removes the ghost node. Safe to call when none is attached. */ + remove: () => void +} + +/** + * The drag image shown while dragging resource rows — the first row's label, plus a count when + * the drag carries a multi-row selection. + * + * Shared so every foldered list lifts rows the same way. The node lives on `document.body` + * rather than in the React tree because `setDragImage` snapshots a real, laid-out element; the + * unmount cleanup is the backstop for a drag whose source row disappears before it ends. + */ +export function useRowDragGhost(): RowDragGhost { + const ghostRef = useRef(null) + + const remove = useCallback(() => { + ghostRef.current?.remove() + ghostRef.current = null + }, []) + + useEffect(() => remove, [remove]) + + const attach = useCallback((e: DragEvent, label: string, count: number) => { + const ghost = document.createElement('div') + ghost.style.cssText = DRAG_GHOST_STYLE + const text = document.createElement('span') + text.style.cssText = DRAG_GHOST_LABEL_STYLE + text.textContent = count > 1 ? `${label} +${count - 1} more` : label + ghost.appendChild(text) + document.body.appendChild(ghost) + // Force a layout pass so the drag image is measurable before it is captured. + void ghost.offsetHeight + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + ghostRef.current = ghost + }, []) + + return { attach, remove } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx new file mode 100644 index 00000000000..b51aaaee64a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx @@ -0,0 +1,200 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + SPRING_LOAD_DELAY_MS, + type SpringLoadedFolder, + type SpringOpenOptions, + useSpringLoadedFolder, +} from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' + +const mountedRoots: Root[] = [] + +interface SpringLoadHarness { + get: () => SpringLoadedFolder + /** Re-renders the probe with a new inline callback, as a parent re-render would. */ + rerender: () => void +} + +function renderSpringLoad( + onSpringOpen: (folderId: string, options: SpringOpenOptions) => void +): SpringLoadHarness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + let result: SpringLoadedFolder | undefined + + function Probe() { + // A fresh arrow each render, mirroring how every real consumer passes this. + result = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => onSpringOpen(folderId, options), + }) + return null + } + + const render = () => { + act(() => { + root.render() + }) + } + + render() + + return { + get: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +/** Advances past the spring delay, flushing the timer callback inside React's act scope. */ +function rest(ms = SPRING_LOAD_DELAY_MS) { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useSpringLoadedFolder', () => { + it('opens the folder after the drag rests on it', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 1) + expect(onSpringOpen).not.toHaveBeenCalled() + + rest(1) + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-a', { history: 'push' }) + }) + + it('does not restart the countdown while the drag stays on one folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + // `dragover` fires continuously; re-arming the same folder must not push the deadline back. + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 100) + act(() => harness.get().arm('folder-a')) + rest(100) + + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-a', { history: 'push' }) + }) + + it('restarts the countdown when the drag moves to another folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest(SPRING_LOAD_DELAY_MS - 100) + act(() => harness.get().arm('folder-b')) + rest(100) + expect(onSpringOpen).not.toHaveBeenCalled() + + rest() + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith('folder-b', { history: 'push' }) + }) + + it('cancels the pending open when the drag leaves the row', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + act(() => harness.get().disarm()) + rest() + + expect(onSpringOpen).not.toHaveBeenCalled() + }) + + it('opens a folder at most once per drag', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + expect(onSpringOpen).toHaveBeenCalledTimes(1) + + // Dragging back out and returning must not re-open it, which would loop at a boundary. + act(() => harness.get().arm('folder-b')) + act(() => harness.get().arm('folder-a')) + rest() + expect(onSpringOpen).toHaveBeenCalledTimes(1) + }) + + it('pushes the first spring-open of a drag and replaces the rest', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().arm('folder-b')) + rest() + + // One gesture, one back-stack entry: Back returns to where the drag started rather than + // replaying every level it passed through, and without eating the entry it started on. + expect(onSpringOpen.mock.calls).toEqual([ + ['folder-a', { history: 'push' }], + ['folder-b', { history: 'replace' }], + ]) + + // A new drag is a new gesture, so its first open pushes again. + act(() => harness.get().reset()) + act(() => harness.get().arm('folder-c')) + rest() + expect(onSpringOpen).toHaveBeenLastCalledWith('folder-c', { history: 'push' }) + }) + + it('keeps a stable handle identity across renders', () => { + // Regression guard: consumers feed this handle into a `useCallback` that a drag-lifecycle + // effect depends on. A fresh object per render re-runs that effect continuously, which tore + // down in-flight drags and silently disabled spring-loading entirely. + const harness = renderSpringLoad(vi.fn()) + + const before = harness.get() + harness.rerender() + harness.rerender() + + expect(harness.get()).toBe(before) + }) + + it('allows the same folder again after the drag ends', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + rest() + act(() => harness.get().reset()) + + act(() => harness.get().arm('folder-a')) + rest() + expect(onSpringOpen).toHaveBeenCalledTimes(2) + }) + + it('never opens a folder after unmount', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm('folder-a')) + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + rest() + + expect(onSpringOpen).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts new file mode 100644 index 00000000000..044322f2e11 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts @@ -0,0 +1,119 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef } from 'react' + +/** + * How long a drag must rest on a folder before it opens. + * + * Matched to macOS Finder's spring-loaded folders, which the feature is modelled on. Shorter + * turns every pass over a folder into an accidental navigation; longer reads as unresponsive + * and the user gives up and drops at the wrong level. + */ +export const SPRING_LOAD_DELAY_MS = 700 + +/** How a spring-open writes the newly opened folder to the browser history. */ +export interface SpringOpenOptions { + history: 'push' | 'replace' +} + +export interface UseSpringLoadedFolderOptions { + /** + * Opens the folder mid-drag. The drag continues in the newly opened folder. + * + * `options.history` is `'push'` for the first folder a drag opens and `'replace'` for every + * one after, so one gesture leaves exactly one back-stack entry and Back returns to the + * folder the drag started in. Pushing every level would record folders the user only rested + * over while deciding where to drop; replacing every level would overwrite the entry they + * were actually standing on, so Back would leave the page instead of returning to it. + */ + onSpringOpen: (folderId: string, options: SpringOpenOptions) => void + delayMs?: number +} + +export interface SpringLoadedFolder { + /** + * Starts (or continues) the timer for `folderId`. Safe to call on every `dragover`, which + * fires continuously: re-arming the folder already being timed does not restart it, so the + * countdown reflects how long the drag has actually rested there. + */ + arm: (folderId: string) => void + /** Cancels the pending open — the drag left the row, or the row stopped being a valid target. */ + disarm: () => void + /** Cancels the pending open and forgets which folders already opened. Call when the drag ends. */ + reset: () => void +} + +/** + * Spring-loaded folders: resting a drag on a folder row opens it, so a resource can be filed + * into a nested folder in one gesture instead of being dropped, navigated, and dragged again. + * + * The dragged rows unmount when the list re-renders into the newly opened folder, which is why + * the drag payload has to live in `dataTransfer` rather than only in the source row's state. + * + * A folder opens at most once per drag. Without that, dragging back out to a parent and + * returning would re-open it on a loop, and a drag that rests near a boundary would flicker + * between two levels. + */ +export function useSpringLoadedFolder({ + onSpringOpen, + delayMs = SPRING_LOAD_DELAY_MS, +}: UseSpringLoadedFolderOptions): SpringLoadedFolder { + const timerRef = useRef | null>(null) + /** Folder the timer is currently counting down for, so re-arming it is a no-op. */ + const armedFolderIdRef = useRef(null) + /** Folders already opened during this drag; each may only spring once. */ + const openedFolderIdsRef = useRef | null>(null) + const openedFolderIds = (openedFolderIdsRef.current ??= new Set()) + + const onSpringOpenRef = useRef(onSpringOpen) + onSpringOpenRef.current = onSpringOpen + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) clearTimeout(timerRef.current) + timerRef.current = null + armedFolderIdRef.current = null + }, []) + + /** A drag can outlive the list that started it; never leave a timer pointing at a dead tree. */ + useEffect(() => clearTimer, [clearTimer]) + + const arm = useCallback( + (folderId: string) => { + if (armedFolderIdRef.current === folderId) return + + /** + * The drag has moved to a different row, so any countdown started on the previous one is + * stale — cancel it before deciding whether this row can spring. Returning early without + * this would let the folder the drag just left open behind the cursor. + */ + clearTimer() + if (openedFolderIds.has(folderId)) return + + armedFolderIdRef.current = folderId + timerRef.current = setTimeout(() => { + timerRef.current = null + armedFolderIdRef.current = null + /** Read before the add: an empty set means nothing has opened in this drag yet. */ + const isFirstOpenOfDrag = openedFolderIds.size === 0 + openedFolderIds.add(folderId) + onSpringOpenRef.current(folderId, { + history: isFirstOpenOfDrag ? 'push' : 'replace', + }) + }, delayMs) + }, + [clearTimer, delayMs, openedFolderIds] + ) + + const reset = useCallback(() => { + clearTimer() + openedFolderIds.clear() + }, [clearTimer, openedFolderIds]) + + /** + * Stable identity, not a fresh object per render. Consumers feed this handle into a + * `useCallback` that a drag-lifecycle effect depends on; a new object each render re-runs + * that effect continuously, and its cleanup then tears down the drag that is still in + * progress. The inner callbacks are already stable, so this memo never invalidates. + */ + return useMemo(() => ({ arm, disarm: clearTimer, reset }), [arm, clearTimer, reset]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/components/index.ts index 16570ad0070..3aeaaeebbeb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/index.ts @@ -4,8 +4,11 @@ export { ErrorShell, ErrorState } from './error' export { InlineRenameInput } from './inline-rename-input' export { IntegrationTabsHeader } from './integration-tabs-header' export { MessageActions } from './message-actions' +export type { BulkOutcome } from './resource/bulk-outcome' +export { reportBulkOutcome } from './resource/bulk-outcome' export { FloatingOverflowText } from './resource/components/floating-overflow-text' -export { ownerCell } from './resource/components/owner-cell' +export type { OwnerAvatarProps } from './resource/components/owner-cell' +export { OwnerAvatar, ownerCell } from './resource/components/owner-cell' export { type ChromeActionSpec, ResourceChromeFallback, @@ -24,7 +27,10 @@ export type { SearchTag, SortConfig, } from './resource/components/resource-options' -export { SortDropdown } from './resource/components/resource-options' +export { + FILTER_SECTION_LABEL_CLASS, + SortDropdown, +} from './resource/components/resource-options' export { timeCell } from './resource/components/time-cell' export type { PaginationConfig, @@ -37,5 +43,8 @@ export type { SelectableConfig, } from './resource/resource' export { EMPTY_CELL_PLACEHOLDER, Resource } from './resource/resource' +export { selectionLabel } from './resource/selection-label' +export type { ResourceRowSelection } from './resource/use-resource-row-selection' +export { useResourceRowSelection } from './resource/use-resource-row-selection' export { ResourceTile } from './resource-tile' export { SkillTile } from './skill-tile' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts new file mode 100644 index 00000000000..2d2817a8bd0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/bulk-outcome.ts @@ -0,0 +1,53 @@ +import { toast } from '@sim/emcn' + +/** An item the batch reached but could not act on, with a reason worth showing. */ +interface BulkFailure { + name: string + reason: string +} + +/** An id that resolved to nothing active — deleted, or not visible to this user. */ +interface BulkMissing { + id: string +} + +export interface BulkOutcome { + failed: BulkFailure[] + notFound: BulkMissing[] +} + +/** + * Reports the parts of a bulk operation that did not happen. + * + * A bulk request succeeds as a whole while individual items are refused (a delete lock, a + * folder cycle) or have vanished since the list was rendered. Those items are the difference + * between what the user selected and what actually changed, so they have to be said out loud — + * the list simply refetching leaves the user to notice a row survived. + * + * Success stays silent, matching the single-item move and delete paths. + * + * @param verb Past-tense verb for the failure sentence, e.g. `'moved'` or `'deleted'`. + */ +export function reportBulkOutcome(outcome: BulkOutcome, verb: string): void { + const { failed, notFound } = outcome + + if (failed.length > 0) { + const [first] = failed + toast.error( + failed.length === 1 + ? `${first.name} could not be ${verb}: ${first.reason}` + : `${failed.length} items could not be ${verb}. ${first.name}: ${first.reason}`, + { duration: 5000 } + ) + return + } + + if (notFound.length > 0) { + toast.error( + notFound.length === 1 + ? `One item was no longer available and was not ${verb}.` + : `${notFound.length} items were no longer available and were not ${verb}.`, + { duration: 5000 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx new file mode 100644 index 00000000000..1a7d864179c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -0,0 +1,146 @@ +'use client' + +import type { ComponentType } from 'react' +import { + Button, + chipFilledFillTokens, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Folder, + Tooltip, + Trash, +} from '@sim/emcn' +import { Download } from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders' + +/** Shared chrome for every action button, so the bar reads as one control strip. */ +const ACTION_BUTTON_CLASS = cn( + chipFilledFillTokens, + 'hover-hover:!text-[var(--text-inverse)] size-[28px] rounded-lg p-0 text-[var(--text-secondary)] hover-hover:bg-[var(--brand-secondary)]' +) + +interface ActionButtonProps { + icon: ComponentType<{ className?: string }> + label: string + onClick: () => void + disabled?: boolean +} + +function ActionButton({ icon: Icon, label, onClick, disabled }: ActionButtonProps) { + return ( + + + + + {label} + + ) +} + +export interface ResourceActionBarProps { + /** The bar is mounted only while this is above zero; it animates in and out on the edges. */ + selectedCount: number + /** Omit on lists with nothing to download (tables, knowledge bases). */ + onDownload?: () => void + /** Both `onMove` and `moveOptions` are required for the move menu to appear. */ + onMove?: (optionValue: string) => void + moveOptions?: MoveOptionNode[] + onDelete?: () => void + /** Disables every action while a bulk mutation is in flight. */ + isLoading?: boolean + className?: string +} + +/** + * Floating bulk-action bar for a `Resource.Table` with checkbox selection, shared so Files, + * Tables, and Knowledge present the same strip in the same place. + * + * Actions are ordered to mirror the row context menu — move before delete, destructive last. + * Each action is opt-in: a list that cannot perform one simply omits its handler, and a reader + * omits the ones they lack permission for. + * + * The entrance is a CSS animation rather than framer-motion: this bar is reachable from three + * list pages, and an animation library on that path costs every one of them ~40 modules of page + * weight for one fade. The trade is that dismissal is instant — an exit animation needs presence + * tracking, which is the part that pulls the library back in. + */ +export function ResourceActionBar({ + selectedCount, + onDownload, + onMove, + moveOptions, + onDelete, + isLoading = false, + className, +}: ResourceActionBarProps) { + if (selectedCount === 0) return null + + return ( +
+
+ + {selectedCount} selected + +
+ {onDownload && ( + + )} + {onMove && moveOptions && ( + + + + + + + + Move + + + {moveOptions.length > 0 && ( + onMove(moveOptions[0].value)}> + + {moveOptions[0].label} + + )} + {moveOptions.length > 1 && } + {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} + + + )} + {onDelete && ( + + )} +
+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts new file mode 100644 index 00000000000..c2c5faaeb72 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/index.ts @@ -0,0 +1,2 @@ +export type { ResourceActionBarProps } from './action-bar' +export { ResourceActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts index fa102e05d3a..22f3365aa43 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/index.ts @@ -1 +1,2 @@ -export { ownerCell } from './owner-cell' +export type { OwnerAvatarProps } from './owner-cell' +export { OwnerAvatar, ownerCell } from './owner-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx index 23a845af1b6..ebbf3bb9e9b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx @@ -2,12 +2,17 @@ import { memo } from 'react' import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' import type { WorkspaceMember } from '@/hooks/queries/workspace' -interface OwnerAvatarProps { +export interface OwnerAvatarProps { name: string image: string | null } -const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { +/** + * The canonical 14px workspace-member avatar — a photo, or the member's initial on a neutral + * disc. Shared so a member reads identically in a resource row's owner cell and in the + * owner/uploaded-by filter options on every list. + */ +export const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { if (image) { return ( {popoverFilter.content} @@ -237,22 +245,23 @@ const SearchSection = memo(function SearchSection({ search }: { search: SearchCo onFocus={search.onFocus} onBlur={search.onBlur} placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')} - className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]' + className={cn(chipFieldTextClass, 'min-w-[80px] flex-1 bg-transparent py-1')} /> {search.tags?.length || search.value ? ( ) : null} {search.dropdown && (
{search.dropdown}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 87c463c3a63..54d9af85f81 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -16,8 +16,10 @@ import { Button, Checkbox, cellIconNodeClass, + chipActiveSurfaceClass, chipContentGap, chipContentLabelClass, + chipHoverSurfaceClass, cn, Loader, } from '@sim/emcn' @@ -586,6 +588,8 @@ const DataRow = memo(function DataRow({ const isDragging = rowDragDrop?.draggedRowIds?.has(row.id) ?? false const isAnyDragActive = rowDragDrop?.isAnyDragActive ?? false const hasActiveSelection = (selectable?.selectedIds.size ?? 0) > 0 + /** Hover and active are mutually exclusive, so a selected row holds its surface through hover. */ + const isRowActive = selectedRowId === row.id || isSelected || isContextMenuTarget const handleClick = useCallback( (e: React.MouseEvent) => { @@ -664,16 +668,20 @@ const DataRow = memo(function DataRow({ className={cn( 'grid w-full transition-colors', isWindowed && 'absolute top-0 left-0', - !isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]', + !isAnyDragActive && !isRowActive && chipHoverSurfaceClass, onRowClick && 'cursor-pointer', isDraggable && 'cursor-grab active:cursor-grabbing', - isDropTarget && 'data-[drop-target=true]:outline-offset-[-1px]', - (selectedRowId === row.id || isSelected || isContextMenuTarget) && 'bg-[var(--surface-3)]', - isActiveDropTarget && 'bg-[var(--surface-4)] outline outline-1 outline-[var(--accent)]', + isRowActive && chipActiveSurfaceClass, + /** + * Drawn inside the row's own box (`outline-offset-[-1px]`) so the ring never overlaps + * the rows above and below, and in the same brand colour as the Files drop-to-upload + * overlay so every "release here" affordance reads as one thing. + */ + isActiveDropTarget && + 'bg-[var(--surface-4)] outline outline-1 outline-[var(--brand-secondary)] outline-offset-[-1px]', (isDragging || (isAnyDragActive && isSelected && !isActiveDropTarget)) && 'opacity-50' )} style={rowStyle} - data-drop-target={isDropTarget || undefined} draggable={isDraggable} onClick={onRowClick || selectable ? handleClick : undefined} onMouseEnter={handleMouseEnter} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts new file mode 100644 index 00000000000..fc812dbee55 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/selection-label.ts @@ -0,0 +1,9 @@ +/** + * Names a multi-row selection for a confirmation prompt: one row reads as itself, several read + * as a count. Shared so the wording stays identical across every resource list — the phrasing + * appears in destructive confirms, where an inconsistency reads as a different action. + */ +export function selectionLabel(count: number, firstName: string | undefined): string { + if (count === 1) return firstName ?? 'selected item' + return `${count} selected items` +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx new file mode 100644 index 00000000000..827b8edc235 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.test.tsx @@ -0,0 +1,173 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + type ResourceRowSelection, + type UseResourceRowSelectionOptions, + useResourceRowSelection, +} from '@/app/workspace/[workspaceId]/components/resource/use-resource-row-selection' + +/** Trees rendered by a test, torn down in afterEach so listeners do not leak across tests. */ +const mountedRoots: Root[] = [] + +interface Harness { + getResult: () => ResourceRowSelection + /** Re-renders with new options, as a parent would when its rows change. */ + rerender: (options: UseResourceRowSelectionOptions) => void +} + +function renderSelection(initialOptions: UseResourceRowSelectionOptions): Harness { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + mountedRoots.push(root) + let result: ResourceRowSelection | undefined + + function Probe({ options }: { options: UseResourceRowSelectionOptions }) { + result = useResourceRowSelection(options) + return null + } + + const render = (options: UseResourceRowSelectionOptions) => { + act(() => { + root.render() + }) + } + + render(initialOptions) + + return { + getResult: () => { + if (!result) throw new Error('Hook result is not ready') + return result + }, + rerender: render, + } +} + +function pressKey(key: string, init: KeyboardEventInit = {}) { + act(() => { + window.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, ...init })) + }) +} + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + document.body.innerHTML = '' +}) + +const ROWS = ['a', 'b', 'c', 'd'] + +describe('useResourceRowSelection', () => { + it('adds and removes a single row', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('b', true)) + expect([...getResult().selectedRowIds]).toEqual(['b']) + + act(() => getResult().selectable.onSelectRow('b', false)) + expect(getResult().selectedRowIds.size).toBe(0) + }) + + it('extends a shift-click range from the last anchor', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('a', true)) + act(() => getResult().selectable.onSelectRow('c', true, true)) + + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'b', 'c']) + }) + + it('treats a shift-click with no anchor as a plain click', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectRow('c', true, true)) + + expect([...getResult().selectedRowIds]).toEqual(['c']) + }) + + it('reports isAllSelected only once every visible row is selected', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'b', 'c', 'd']) + expect(getResult().selectable.isAllSelected).toBe(true) + + act(() => getResult().selectable.onSelectRow('b', false)) + expect(getResult().selectable.isAllSelected).toBe(false) + }) + + it('drops rows that are no longer visible', () => { + const { getResult, rerender } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + rerender({ visibleRowIds: ['a', 'c'] }) + + expect([...getResult().selectedRowIds].sort()).toEqual(['a', 'c']) + }) + + it('replaceSelection collapses onto the given rows and re-anchors a single row', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + act(() => getResult().selectable.onSelectAll(true)) + act(() => getResult().replaceSelection(['b'])) + expect([...getResult().selectedRowIds]).toEqual(['b']) + + // 'b' became the anchor, so a shift-click on 'd' fills the range from there. + act(() => getResult().selectable.onSelectRow('d', true, true)) + expect([...getResult().selectedRowIds].sort()).toEqual(['b', 'c', 'd']) + }) + + it('selects every visible row on Cmd+A and clears on Escape', () => { + const { getResult } = renderSelection({ visibleRowIds: ROWS }) + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(ROWS.length) + + pressKey('Escape') + expect(getResult().selectedRowIds.size).toBe(0) + }) + + it('calls onDeleteSelected for Delete only while rows are selected', () => { + const onDeleteSelected = vi.fn() + const { getResult } = renderSelection({ visibleRowIds: ROWS, onDeleteSelected }) + + pressKey('Delete') + expect(onDeleteSelected).not.toHaveBeenCalled() + + act(() => getResult().selectable.onSelectRow('a', true)) + pressKey('Delete') + expect(onDeleteSelected).toHaveBeenCalledTimes(1) + }) + + it('ignores shortcuts while blocked or while a text field has focus', () => { + const onDeleteSelected = vi.fn() + const blocked = { current: true } + const { getResult } = renderSelection({ + visibleRowIds: ROWS, + isKeyboardBlocked: () => blocked.current, + onDeleteSelected, + }) + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(0) + + blocked.current = false + const input = document.createElement('input') + document.body.appendChild(input) + input.focus() + + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(0) + + input.blur() + pressKey('a', { metaKey: true }) + expect(getResult().selectedRowIds.size).toBe(ROWS.length) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts new file mode 100644 index 00000000000..ad3c8674454 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts @@ -0,0 +1,200 @@ +'use client' + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { SelectableConfig } from '@/app/workspace/[workspaceId]/components/resource/resource' + +/** Shared empty set so an empty selection keeps a stable identity across renders. */ +const EMPTY_ROW_IDS = new Set() + +/** Sentinel for "no shift-range anchor", so index 0 stays a usable anchor. */ +const NO_ANCHOR = -1 + +/** + * True while a text-entry surface owns the keystroke, so the list shortcuts never eat a + * character the user is typing into a rename field, a search box, or an editor. + */ +function isTypingTarget(): boolean { + const active = document.activeElement + if (!active) return false + return ( + active.tagName === 'INPUT' || + active.tagName === 'TEXTAREA' || + (active as HTMLElement).isContentEditable + ) +} + +export interface UseResourceRowSelectionOptions { + /** + * Row ids currently rendered, in display order. Selection is pruned to this list whenever it + * changes (navigating into a folder, applying a filter) and shift-ranges walk it, so it must + * be the same array identity across renders that do not change the rows. + */ + visibleRowIds: string[] + /** + * Blocks the keyboard shortcuts while another surface owns the keystroke — a detail view open + * over the list, an inline rename in progress, a modal. Text inputs are already excluded. + */ + isKeyboardBlocked?: () => boolean + /** Bound to Delete/Backspace on a non-empty selection. Omit to leave those keys unbound. */ + onDeleteSelected?: () => void +} + +export interface ResourceRowSelection { + selectedRowIds: Set + /** Passed straight to `Resource.Table`'s `selectable` prop. */ + selectable: SelectableConfig + /** Collapses the selection to exactly these rows, e.g. a plain row click or a drag start. */ + replaceSelection: (rowIds: Iterable) => void + clearSelection: () => void +} + +/** + * Checkbox selection for a `Resource.Table` list: click, shift-click ranges, select-all, and the + * Cmd/Ctrl+A · Escape · Delete shortcuts, shared so Files, Tables, and Knowledge select + * identically rather than each re-deriving the same state machine. + * + * Selection is keyed by *row* id, not resource id, so a foldered list can hold folder rows and + * resource rows in one selection; consumers split it back out with `parseFolderedRowId`. + */ +export function useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked, + onDeleteSelected, +}: UseResourceRowSelectionOptions): ResourceRowSelection { + const [selectedRowIds, setSelectedRowIds] = useState>(() => EMPTY_ROW_IDS) + + /** Anchor for shift-click ranges — an index into `visibleRowIds`, not a row id. */ + const anchorIndexRef = useRef(NO_ANCHOR) + + const visibleRowIdsRef = useRef(visibleRowIds) + visibleRowIdsRef.current = visibleRowIds + const isKeyboardBlockedRef = useRef(isKeyboardBlocked) + isKeyboardBlockedRef.current = isKeyboardBlocked + const onDeleteSelectedRef = useRef(onDeleteSelected) + onDeleteSelectedRef.current = onDeleteSelected + + const clearSelection = useCallback(() => { + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds((prev) => (prev.size === 0 ? prev : EMPTY_ROW_IDS)) + }, []) + + const replaceSelection = useCallback((rowIds: Iterable) => { + const next = new Set(rowIds) + /** + * A single row becomes the next shift anchor; a multi-row replacement has no meaningful + * anchor, so the following shift-click starts a fresh range instead of extending from a + * row the user never clicked. + */ + let anchor = NO_ANCHOR + if (next.size === 1) { + for (const rowId of next) anchor = visibleRowIdsRef.current.indexOf(rowId) + } + anchorIndexRef.current = anchor + setSelectedRowIds(next) + }, []) + + /** + * Rows that left the list — navigating into a folder, applying a filter — are gone as far as + * selection is concerned, otherwise a bulk action would silently operate on rows the user can + * no longer see. Compared by identity because `visibleRowIds` is memoized upstream and only + * changes when the rows really change. + */ + const prevVisibleRowIdsRef = useRef(visibleRowIds) + useEffect(() => { + if (prevVisibleRowIdsRef.current === visibleRowIds) return + prevVisibleRowIdsRef.current = visibleRowIds + anchorIndexRef.current = NO_ANCHOR + const visible = new Set(visibleRowIds) + setSelectedRowIds((prev) => { + if (prev.size === 0) return prev + const next = new Set() + for (const rowId of prev) if (visible.has(rowId)) next.add(rowId) + return next.size === prev.size ? prev : next + }) + }, [visibleRowIds]) + + /** + * The size check short-circuits the common case (a selection smaller than the list) in O(1); + * this runs on every render of the page, including each one a drag triggers. + */ + const isAllSelected = + visibleRowIds.length > 0 && + selectedRowIds.size >= visibleRowIds.length && + visibleRowIds.every((rowId) => selectedRowIds.has(rowId)) + + const selectable = useMemo( + () => ({ + selectedIds: selectedRowIds, + isAllSelected, + onSelectRow: (rowId, checked, shiftKey) => { + const currentIndex = visibleRowIds.indexOf(rowId) + if (shiftKey && anchorIndexRef.current !== NO_ANCHOR && currentIndex !== NO_ANCHOR) { + const start = Math.min(anchorIndexRef.current, currentIndex) + const end = Math.max(anchorIndexRef.current, currentIndex) + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) + return next + }) + anchorIndexRef.current = currentIndex + return + } + setSelectedRowIds((prev) => { + const next = new Set(prev) + if (checked) next.add(rowId) + else next.delete(rowId) + return next + }) + anchorIndexRef.current = checked ? currentIndex : NO_ANCHOR + }, + onSelectAll: (checked) => { + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds((prev) => { + const next = new Set(prev) + for (const rowId of visibleRowIds) { + if (checked) next.add(rowId) + else next.delete(rowId) + } + return next + }) + }, + disabled: false, + }), + [selectedRowIds, isAllSelected, visibleRowIds] + ) + + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (isKeyboardBlockedRef.current?.()) return + if (isTypingTarget()) return + + const hasSelection = selectedRowIdsRef.current.size > 0 + + if ((e.key === 'Delete' || e.key === 'Backspace') && hasSelection) { + if (!onDeleteSelectedRef.current) return + e.preventDefault() + onDeleteSelectedRef.current() + return + } + + if (e.key === 'Escape' && hasSelection) { + e.preventDefault() + clearSelection() + return + } + + if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { + e.preventDefault() + anchorIndexRef.current = NO_ANCHOR + setSelectedRowIds(new Set(visibleRowIdsRef.current)) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [clearSelection]) + + return { selectedRowIds, selectable, replaceSelection, clearSelection } +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx deleted file mode 100644 index 53ebe27ba84..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/action-bar.tsx +++ /dev/null @@ -1,126 +0,0 @@ -'use client' -import { - Button, - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - Folder, - Tooltip, - Trash, -} from '@sim/emcn' -import { Download } from '@sim/emcn/icons' -import { AnimatePresence, domAnimation, LazyMotion, m } from 'framer-motion' -import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' -import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders' - -interface FilesActionBarProps { - selectedCount: number - onDownload?: () => void - onMove?: (optionValue: string) => void - moveOptions?: MoveOptionNode[] - onDelete?: () => void - isLoading?: boolean - className?: string -} - -export function FilesActionBar({ - selectedCount, - onDownload, - onMove, - moveOptions, - onDelete, - isLoading = false, - className, -}: FilesActionBarProps) { - return ( - - - {selectedCount > 0 && ( - -
- - {selectedCount} selected - -
- {onDownload && ( - - - - - Download - - )} - {onMove && moveOptions && ( - - - - - - - - Move - - - {moveOptions.length > 0 && ( - onMove(moveOptions[0].value)}> - - {moveOptions[0].label} - - )} - {moveOptions.length > 1 && } - {moveOptions.slice(1).map((option) => renderMoveOption(option, onMove))} - - - )} - {onDelete && ( - - - - - Delete - - )} -
-
-
- )} -
-
- ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts deleted file mode 100644 index aa19162a077..00000000000 --- a/apps/sim/app/workspace/[workspaceId]/files/components/action-bar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FilesActionBar } from './action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 3b2eb0c2989..b3b87df37d7 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -57,9 +57,13 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -67,14 +71,20 @@ import type { } from '@/app/workspace/[workspaceId]/components/folders' import { breadcrumbFolderChain, + buildDescendantIndex, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, folderedResourceListHref, parseMoveOptionValue, - ROOT_MOVE_OPTION_VALUE, + readRowDragPayload, sortResources, + useDragTeardown, + useRowDragGhost, + useSpringLoadedFolder, + writeRowDragPayload, } from '@/app/workspace/[workspaceId]/components/folders' -import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal' import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu' import type { PreviewMode } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -141,6 +151,15 @@ type FileListEntry = const logger = createLogger('Files') +/** + * Private drag payload for file rows, kept distinct from the foldered-list MIME so a drag + * started on Tables or Knowledge is never mistaken for one of these rows. + */ +const FILE_ROW_DRAG_MIME = 'application/x-sim-workspace-file-rows' + +/** Shared empty set so an idle drag state keeps a stable identity across renders. */ +const EMPTY_DRAGGED_ROW_IDS = new Set() + const FILES_HEADER = FOLDERED_RESOURCE_HEADERS.file const FOLDER_ICON = @@ -299,12 +318,13 @@ export function Files() { const foldersRef = useRef(folders) foldersRef.current = folders - const [uploading, setUploading] = useState(false) const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0, currentPercent: 0, }) + /** An upload batch is in flight exactly while a total is set — matches the Tables page. */ + const uploading = uploadProgress.total > 0 const [isDraggingOver, setIsDraggingOver] = useState(false) const dragCounterRef = useRef(0) const [ @@ -347,9 +367,8 @@ export function Files() { const [creatingFile, setCreatingFile] = useState(false) const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') - const [selectedRowIds, setSelectedRowIds] = useState>(() => new Set()) const [activeDropTargetId, setActiveDropTargetId] = useState(null) - const [draggedRowIds, setDraggedRowIds] = useState>(() => new Set()) + const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_DRAGGED_ROW_IDS) const [previewMode, setPreviewMode] = useState(() => { if (isNewFile) return 'editor' if (fileIdFromRoute) { @@ -362,9 +381,7 @@ export function Files() { const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const contextMenuItemRef = useRef(null) - const lastSelectedIndexRef = useRef(-1) const draggedRowIdsRef = useRef([]) - const dragGhostRef = useRef(null) const [deleteTarget, setDeleteTarget] = useState<{ fileIds: string[] folderIds: string[] @@ -676,21 +693,17 @@ export function Files() { const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) - const prevVisibleRowIdsRef = useRef(visibleRowIds) - useEffect(() => { - if (prevVisibleRowIdsRef.current === visibleRowIds) return - prevVisibleRowIdsRef.current = visibleRowIds - lastSelectedIndexRef.current = -1 - const visible = new Set(visibleRowIds) - setSelectedRowIds((prev) => { - if (prev.size === 0) return prev - const next = new Set(Array.from(prev).filter((id) => visible.has(id))) - return next.size === prev.size ? prev : next - }) - }, [visibleRowIds]) + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => Boolean(fileIdFromRoute) || listRename.editingId !== null, + onDeleteSelected: () => handleBulkDelete(), + }) - const isAllSelected = - visibleRowIds.length > 0 && visibleRowIds.every((id) => selectedRowIds.has(id)) const { selectedFileIds, selectedFolderIds } = useMemo(() => { const fileIds: string[] = [] const folderIds: string[] = [] @@ -702,82 +715,7 @@ export function Files() { return { selectedFileIds: fileIds, selectedFolderIds: folderIds } }, [selectedRowIds]) - const selectableConfig = useMemo( - () => ({ - selectedIds: selectedRowIds, - isAllSelected, - onSelectRow: (rowId: string, checked: boolean, shiftKey?: boolean) => { - const currentIndex = visibleRowIds.indexOf(rowId) - if (shiftKey && lastSelectedIndexRef.current !== -1 && currentIndex !== -1) { - const start = Math.min(lastSelectedIndexRef.current, currentIndex) - const end = Math.max(lastSelectedIndexRef.current, currentIndex) - setSelectedRowIds((prev) => { - const next = new Set(prev) - for (let i = start; i <= end; i++) next.add(visibleRowIds[i]) - return next - }) - lastSelectedIndexRef.current = currentIndex - } else { - setSelectedRowIds((prev) => { - const next = new Set(prev) - if (checked) next.add(rowId) - else next.delete(rowId) - return next - }) - if (checked) lastSelectedIndexRef.current = currentIndex - else lastSelectedIndexRef.current = -1 - } - }, - onSelectAll: (checked: boolean) => { - lastSelectedIndexRef.current = -1 - setSelectedRowIds((prev) => { - const next = new Set(prev) - for (const rowId of visibleRowIds) { - if (checked) next.add(rowId) - else next.delete(rowId) - } - return next - }) - }, - disabled: false, - }), - [selectedRowIds, isAllSelected, visibleRowIds] - ) - - const descendantFolderIdsByFolderId = useMemo(() => { - const childrenByParent = new Map() - for (const folder of folders) { - if (!folder.parentId) continue - const children = childrenByParent.get(folder.parentId) ?? [] - children.push(folder.id) - childrenByParent.set(folder.parentId, children) - } - - const result = new Map>() - const collect = (folderId: string, seen = new Set()): Set => { - const cached = result.get(folderId) - if (cached) return cached - if (seen.has(folderId)) return new Set() - - const nextSeen = new Set(seen) - nextSeen.add(folderId) - const descendants = new Set() - for (const childId of childrenByParent.get(folderId) ?? []) { - if (nextSeen.has(childId)) continue - descendants.add(childId) - for (const nestedId of collect(childId, nextSeen)) { - descendants.add(nestedId) - } - } - result.set(folderId, descendants) - return descendants - } - - for (const folder of folders) { - collect(folder.id) - } - return result - }, [folders]) + const descendantFolderIdsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) const isInvalidDropTarget = useCallback( (targetRowId: string, sourceRowIds: string[]) => { @@ -791,7 +729,6 @@ export function Files() { if (descendantFolderIdsByFolderId.get(source.id)?.has(target.id)) return true } - // Reject drop if every dragged item is already a direct child of the target const allAlreadyInTarget = sourceRowIds.every((sourceRowId) => { const source = parseRowId(sourceRowId) if (source.kind === 'file') { @@ -841,7 +778,6 @@ export function Files() { if (allowedFiles.length === 0) return try { - setUploading(true) setUploadProgress({ completed: 0, total: allowedFiles.length, currentPercent: 0 }) for (let i = 0; i < allowedFiles.length; i++) { @@ -872,13 +808,33 @@ export function Files() { } catch (err) { logger.error('Error uploading file:', err) } finally { - setUploading(false) setUploadProgress({ completed: 0, total: 0, currentPercent: 0 }) } }, [workspaceId, canEdit, currentFolderId, notifyLimit] ) + const dragGhost = useRowDragGhost() + + const springLoad = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => { + void setFilesParams({ folderId, new: null }, options) + }, + }) + + /** Returns the list to its resting state once a drag is over, however it ended. */ + const endDrag = useCallback(() => { + dragGhost.remove() + dragCounterRef.current = 0 + draggedRowIdsRef.current = [] + springLoad.reset() + setDraggedRowIds(EMPTY_DRAGGED_ROW_IDS) + setIsDraggingOver(false) + setActiveDropTargetId(null) + }, [dragGhost, springLoad]) + + useDragTeardown(endDrag) + const rowDragDropConfig = useMemo( () => ({ activeDropTargetId, @@ -899,35 +855,18 @@ export function Files() { draggedRowIdsRef.current = sourceRowIds setDraggedRowIds(new Set(sourceRowIds)) if (!selectedRowIds.has(rowId)) { - setSelectedRowIds(new Set([rowId])) + replaceSelection([rowId]) } e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData( - 'application/x-sim-workspace-file-rows', - JSON.stringify(sourceRowIds) - ) - e.dataTransfer.setData('text/plain', sourceRowIds.join(',')) + writeRowDragPayload(e.dataTransfer, FILE_ROW_DRAG_MIME, sourceRowIds) - const count = sourceRowIds.length const firstParsed = parseRowId(sourceRowIds[0]) const firstName = firstParsed.kind === 'file' ? filesRef.current.find((f) => f.id === firstParsed.id)?.name : foldersRef.current.find((f) => f.id === firstParsed.id)?.name - const ghostLabel = - count > 1 ? `${firstName ?? 'Items'} +${count - 1} more` : (firstName ?? 'Item') - const ghost = document.createElement('div') - ghost.style.cssText = - 'position:fixed;top:-500px;left:0;display:inline-flex;align-items:center;padding:4px 10px;background:var(--surface-active);border:1px solid var(--border);border-radius:8px;font-family:system-ui,-apple-system,sans-serif;font-size:13px;color:var(--text-body);white-space:nowrap;pointer-events:none;box-shadow:var(--shadow-medium);z-index:var(--z-toast)' - const text = document.createElement('span') - text.style.cssText = 'max-width:200px;overflow:hidden;text-overflow:ellipsis' - text.textContent = ghostLabel - ghost.appendChild(text) - document.body.appendChild(ghost) - void ghost.offsetHeight - e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) - dragGhostRef.current = ghost + dragGhost.attach(e, firstName ?? 'Item', sourceRowIds.length) }, onDragOver: (e: DragEvent, rowId) => { const sourceRowIds = draggedRowIdsRef.current @@ -938,42 +877,42 @@ export function Files() { e.stopPropagation() e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move' setActiveDropTargetId(rowId) + /** + * Armed for OS file drags too: dropping an upload into a nested folder is the same + * gesture, and `onDragOver` only fires on folder rows. + */ + springLoad.arm(parseRowId(rowId).id) }, onDragLeave: (e: DragEvent, rowId) => { const relatedTarget = e.relatedTarget if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + springLoad.disarm() setActiveDropTargetId((current) => (current === rowId ? null : current)) }, onDrop: (e: DragEvent, rowId) => { e.preventDefault() e.stopPropagation() - dragCounterRef.current = 0 - setIsDraggingOver(false) - setActiveDropTargetId(null) + const target = parseRowId(rowId) + const droppedFiles = Array.from(e.dataTransfer.files ?? []) + const sourceRowIds = + readRowDragPayload(e.dataTransfer, FILE_ROW_DRAG_MIME) ?? draggedRowIdsRef.current + + /** + * Ends the drag before dispatching, but only after the payload has been read off the + * event and the source ref. This handler stops propagation, so the window-level + * backstop never sees this drop, and the source row may already have unmounted — after + * a spring-open it always has. + */ + endDrag() + if (target.kind !== 'folder') return - const droppedFiles = Array.from(e.dataTransfer.files ?? []) if (droppedFiles.length > 0) { void uploadFiles(droppedFiles, target.id) return } - let sourceRowIds = draggedRowIdsRef.current - const rawSource = e.dataTransfer.getData('application/x-sim-workspace-file-rows') - if (rawSource) { - try { - const parsedSource = JSON.parse(rawSource) - if (Array.isArray(parsedSource)) { - sourceRowIds = parsedSource.filter( - (source): source is string => typeof source === 'string' && source.length > 0 - ) - } - } catch { - sourceRowIds = draggedRowIdsRef.current - } - } - if (isInvalidDropTarget(rowId, sourceRowIds)) return const fileIds = sourceRowIds @@ -995,23 +934,13 @@ export function Files() { targetFolderId: target.id, }) .then(() => { - setSelectedRowIds(new Set()) + clearSelection() }) .catch((error) => { logger.error('Failed to move items via drag and drop:', error) }) }, - onDragEnd: () => { - if (dragGhostRef.current) { - dragGhostRef.current.remove() - dragGhostRef.current = null - } - dragCounterRef.current = 0 - draggedRowIdsRef.current = [] - setDraggedRowIds(new Set()) - setIsDraggingOver(false) - setActiveDropTargetId(null) - }, + onDragEnd: endDrag, }), [ activeDropTargetId, @@ -1106,7 +1035,7 @@ export function Files() { } setShowDeleteConfirm(false) setDeleteTarget(null) - setSelectedRowIds(new Set()) + clearSelection() if (target.fileIds.includes(fileIdFromRouteRef.current ?? '')) { setIsDirty(false) setSaveStatus('idle') @@ -1179,12 +1108,11 @@ export function Files() { setDeleteTarget({ fileIds: selectedFileIds, folderIds: selectedFolderIds, - name: - selectedFileIds.length + selectedFolderIds.length === 1 - ? (files.find((file) => file.id === selectedFileIds[0])?.name ?? - folders.find((folder) => folder.id === selectedFolderIds[0])?.name ?? - 'selected item') - : `${selectedFileIds.length + selectedFolderIds.length} selected items`, + name: selectionLabel( + selectedFileIds.length + selectedFolderIds.length, + files.find((file) => file.id === selectedFileIds[0])?.name ?? + folders.find((folder) => folder.id === selectedFolderIds[0])?.name + ), }) setShowDeleteConfirm(true) }, [selectedFileIds, selectedFolderIds, files, folders]) @@ -1363,12 +1291,11 @@ export function Files() { ? { kind: 'folder', id: parsed.id, folder: item as WorkspaceFileFolderApi } : { kind: 'file', id: parsed.id, file: item as WorkspaceFileRecord } if (!selectedRowIds.has(rowId)) { - lastSelectedIndexRef.current = visibleRowIds.indexOf(rowId) - setSelectedRowIds(new Set([rowId])) + replaceSelection([rowId]) } openContextMenu(e) }, - [folders, openContextMenu, selectedRowIds, visibleRowIds] + [folders, openContextMenu, selectedRowIds] ) const handleContextMenuOpen = useCallback(() => { @@ -1459,7 +1386,7 @@ export function Files() { folderIds: selectedFolderIds, targetFolderId, }) - setSelectedRowIds(new Set()) + clearSelection() closeContextMenu() } catch (error) { logger.error('Failed to move items:', error) @@ -1532,49 +1459,6 @@ export function Files() { return () => window.removeEventListener('keydown', handleKeyDown) }, [handleSave]) - const selectedRowIdsRef = useRef(selectedRowIds) - selectedRowIdsRef.current = selectedRowIds - const visibleRowIdsRef = useRef(visibleRowIds) - visibleRowIdsRef.current = visibleRowIds - const listRenameActiveRef = useRef(listRename.editingId) - listRenameActiveRef.current = listRename.editingId - const handleBulkDeleteRef = useRef(handleBulkDelete) - handleBulkDeleteRef.current = handleBulkDelete - - useEffect(() => { - const handleListKeyDown = (e: KeyboardEvent) => { - if (fileIdFromRouteRef.current) return - const active = document.activeElement - if ( - active && - (active.tagName === 'INPUT' || - active.tagName === 'TEXTAREA' || - (active as HTMLElement).isContentEditable) - ) - return - if (listRenameActiveRef.current) return - - if ((e.key === 'Delete' || e.key === 'Backspace') && selectedRowIdsRef.current.size > 0) { - e.preventDefault() - handleBulkDeleteRef.current() - return - } - - if (e.key === 'Escape' && selectedRowIdsRef.current.size > 0) { - e.preventDefault() - setSelectedRowIds(new Set()) - return - } - - if ((e.metaKey || e.ctrlKey) && e.key === 'a' && visibleRowIdsRef.current.length > 0) { - e.preventDefault() - setSelectedRowIds(new Set(visibleRowIdsRef.current)) - } - } - window.addEventListener('keydown', handleListKeyDown) - return () => window.removeEventListener('keydown', handleListKeyDown) - }, []) - const handleCyclePreviewMode = useCallback(() => { setPreviewMode((prev) => { if (prev === 'editor') return 'split' @@ -1692,21 +1576,21 @@ export function Files() { { id: 'file-delete', handler: () => handleDeleteSelected() }, ]) - const searchConfig: SearchConfig = { - value: urlSearchTerm, - onChange: setSearchTerm, - onClearAll: () => setSearchTerm(''), - placeholder: 'Search files...', - } + const searchConfig: SearchConfig = useMemo( + () => ({ + value: urlSearchTerm, + onChange: setSearchTerm, + onClearAll: () => setSearchTerm(''), + placeholder: 'Search files...', + }), + [urlSearchTerm, setSearchTerm] + ) - const uploadButtonLabel = - uploading && uploadProgress.total > 0 - ? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100 - ? `${uploadProgress.completed}/${uploadProgress.total} · ${uploadProgress.currentPercent}%` - : `${uploadProgress.completed}/${uploadProgress.total}` - : uploading - ? 'Uploading...' - : 'Upload' + const uploadButtonLabel = uploading + ? uploadProgress.currentPercent > 0 && uploadProgress.currentPercent < 100 + ? `${uploadProgress.completed}/${uploadProgress.total} · ${uploadProgress.currentPercent}%` + : `${uploadProgress.completed}/${uploadProgress.total}` + : 'Upload' const headerActionsConfig = useMemo( () => [ @@ -1827,45 +1711,21 @@ export function Files() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) - const contextMenuMoveOptions = useMemo((): MoveOptionNode[] => { - // Index children by parent ONCE (the same pattern used for folder sizes + descendant maps above), - // so building the tree is O(N) instead of a full `folders.filter` scan at every node (O(N²)). - const childrenByParent = new Map() - for (const f of folders) { - const key = f.parentId ?? null - const arr = childrenByParent.get(key) - if (arr) arr.push(f) - else childrenByParent.set(key, [f]) - } - const buildSubtree = (parentId: string | null): MoveOptionNode[] => - (childrenByParent.get(parentId) ?? []) - .filter((f) => { - if (selectedFolderIds.includes(f.id)) return false - return selectedFolderIds.every( - (sid) => !descendantFolderIdsByFolderId.get(sid)?.has(f.id) - ) - }) - .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)) - .map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) })) - - return [{ value: ROOT_MOVE_OPTION_VALUE, label: 'Files', children: [] }, ...buildSubtree(null)] - }, [folders, selectedFolderIds, descendantFolderIdsByFolderId]) + const contextMenuMoveOptions = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Files', + excludeFolderIds: selectedFolderIds, + descendantsByFolderId: descendantFolderIdsByFolderId, + }), + [folders, selectedFolderIds, descendantFolderIdsByFolderId] + ) const sortConfig: SortConfig = useMemo( () => ({ @@ -1921,7 +1781,7 @@ export function Files() { return (
- File Type + File Type
- Size + Size {memberOptions.length > 0 && (
- Uploaded By + Uploaded By ({ content: filterContent }), [filterContent]) + const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (typeFilter.length > 0) { @@ -2130,7 +1993,7 @@ export function Files() { search={searchConfig} sort={sortConfig} filterTags={filterTags} - filter={filterContent ? { content: filterContent } : undefined} + filter={filterConfig} /> -
-

Drop to upload

-

+

Drop to upload

+

Release files here to add them to this workspace

diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx index 3d71ef5e63b..b64b4642abb 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx @@ -50,7 +50,11 @@ import type { SelectableConfig, SortConfig, } from '@/app/workspace/[workspaceId]/components' -import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components' +import { + FILTER_SECTION_LABEL_CLASS, + FloatingOverflowText, + Resource, +} from '@/app/workspace/[workspaceId]/components' import { FOLDERED_RESOURCE_HEADERS, folderBreadcrumbItems, @@ -125,8 +129,6 @@ const STATUS_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'disabled', label: 'Disabled' }, ] -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' - interface KnowledgeBaseProps { id: string knowledgeBaseName?: string diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index e01bee4bd83..71dd6bed37d 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -22,9 +22,14 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + reportBulkOutcome, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -33,6 +38,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, @@ -42,9 +48,11 @@ import { parseFolderedRowId, parseMoveOptionValue, sortResources, + splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { BaseTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/components' import { CreateBaseModal, @@ -65,7 +73,12 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb import { CONNECTOR_META_REGISTRY } from '@/connectors/registry' import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge' import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders' -import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge' +import { + useBulkDeleteKnowledgeBases, + useBulkMoveKnowledgeBases, + useDeleteKnowledgeBase, + useUpdateKnowledgeBase, +} from '@/hooks/queries/kb/knowledge' import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace' import { useDebounce } from '@/hooks/use-debounce' @@ -110,8 +123,6 @@ const CONTENT_FILTER_OPTIONS: ChipDropdownOption[] = [ { value: 'empty', label: 'Empty' }, ] -const FILTER_SECTION_LABEL_CLASS = 'text-[var(--text-muted)] text-small' - const FOLDER_RESOURCE_TYPE = 'knowledge_base' as const const ROOT_BREADCRUMB_LABEL = FOLDERED_RESOURCE_HEADERS[FOLDER_RESOURCE_TYPE].rootLabel @@ -200,9 +211,14 @@ export function Knowledge() { }, [error]) const userPermissions = useUserPermissionsContext() + const canEdit = userPermissions.canEdit === true + const canEditRef = useRef(canEdit) + canEditRef.current = canEdit const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId) - const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId) + const deleteKnowledgeBase = useDeleteKnowledgeBase(workspaceId) + const bulkMoveKnowledgeBases = useBulkMoveKnowledgeBases(workspaceId) + const bulkDeleteKnowledgeBases = useBulkDeleteKnowledgeBases(workspaceId) const { currentFolderId, @@ -268,8 +284,8 @@ export function Knowledge() { ) const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false) + const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false) const [isTagsModalOpen, setIsTagsModalOpen] = useState(false) - const [isDeleting, setIsDeleting] = useState(false) const [activeFolder, setActiveFolder] = useState(null) const [folderPendingDelete, setFolderPendingDelete] = useState(null) @@ -400,10 +416,11 @@ export function Knowledge() { const handleDeleteKnowledgeBase = useCallback( async (id: string) => { - await deleteKnowledgeBaseMutation({ knowledgeBaseId: id }) + await deleteKnowledgeBase.mutateAsync({ knowledgeBaseId: id }) logger.info(`Knowledge base deleted: ${id}`) }, - [deleteKnowledgeBaseMutation] + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + [] ) /** @@ -613,6 +630,44 @@ export function Knowledge() { listRename.cancelRename, ]) + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) + + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => !canEdit || listRenameRef.current.editingId !== null, + onDeleteSelected: () => handleBulkDelete(), + }) + + const selectedRowIdsRef = useRef(selectedRowIds) + selectedRowIdsRef.current = selectedRowIds + + /** + * A context menu opened on a multi-row selection acts on the whole selection. Resolved inside + * the menu handlers rather than at each menu prop, so the menus stay unaware selection exists. + */ + const hasMultiSelection = selectedRowIds.size > 1 + const hasMultiSelectionRef = useRef(hasMultiSelection) + hasMultiSelectionRef.current = hasMultiSelection + + const { folderIds: selectedFolderIds, resourceIds: selectedKnowledgeBaseIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] + ) + + const bulkDeleteLabel = useMemo(() => { + const count = selectedKnowledgeBaseIds.length + selectedFolderIds.length + const firstName = + selectedKnowledgeBaseIds.length > 0 + ? knowledgeBasesRef.current.find((kb) => kb.id === selectedKnowledgeBaseIds[0])?.name + : foldersRef.current.find((folder) => folder.id === selectedFolderIds[0])?.name + return selectionLabel(count, firstName) + }, [selectedKnowledgeBaseIds, selectedFolderIds]) + const handleRowClick = useCallback( (rowId: string) => { if (isRowContextMenuOpenRef.current || isFolderContextMenuOpenRef.current) return @@ -634,6 +689,13 @@ export function Knowledge() { const handleRowContextMenu = useCallback( (e: React.MouseEvent, rowId: string) => { + /** + * Right-clicking outside the selection retargets it, so the menu always acts on what is + * highlighted. Right-clicking inside it leaves the selection alone and the menu switches + * its move/delete entries to the bulk handlers. + */ + if (canEditRef.current && !selectedRowIdsRef.current.has(rowId)) replaceSelection([rowId]) + const parsed = parseFolderedRowId(rowId) if (parsed.kind === 'folder') { const folder = foldersRef.current.find((item) => item.id === parsed.id) @@ -655,14 +717,9 @@ export function Knowledge() { const handleConfirmDelete = useCallback(async () => { const kb = activeKnowledgeBaseRef.current if (!kb) return - setIsDeleting(true) - try { - await handleDeleteKnowledgeBase(kb.id) - setIsDeleteModalOpen(false) - setActiveKnowledgeBase(null) - } finally { - setIsDeleting(false) - } + await handleDeleteKnowledgeBase(kb.id) + setIsDeleteModalOpen(false) + setActiveKnowledgeBase(null) }, [handleDeleteKnowledgeBase]) const handleCloseDeleteModal = useCallback(() => { @@ -696,8 +753,6 @@ export function Knowledge() { setIsDeleteModalOpen(true) }, []) - const canEdit = userPermissions.canEdit === true - const handleCreateFolder = useCallback(async () => { if (!workspaceId) return const parentId = currentFolderIdRef.current @@ -800,16 +855,18 @@ export function Knowledge() { }, [workspaceId, pinnedFolderIds, closeFolderContextMenu]) /** Move targets for the folder under the cursor: itself and its subtree are unreachable. */ - const folderMoveOptions: MoveOptionNode[] = useMemo(() => { - if (!activeFolder) return [] - const excluded = new Set([activeFolder.id]) - for (const id of descendantsByFolderId.get(activeFolder.id) ?? []) excluded.add(id) - return buildMoveOptions({ - folders, - rootLabel: ROOT_BREADCRUMB_LABEL, - excludedFolderIds: excluded, - }) - }, [folders, activeFolder, descendantsByFolderId]) + const folderMoveOptions: MoveOptionNode[] = useMemo( + () => + activeFolder + ? buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludeFolderIds: [activeFolder.id], + descendantsByFolderId, + }) + : [], + [folders, activeFolder, descendantsByFolderId] + ) /** Move targets for a knowledge base: every folder, since a base has no subtree. */ const knowledgeBaseMoveOptions: MoveOptionNode[] = useMemo( @@ -855,8 +912,7 @@ export function Knowledge() { if (!folder) return const parentId = parseMoveOptionValue(optionValue) // Live placement, not the snapshot taken when the menu opened — a refetch or concurrent - // move in between would otherwise skip the write the user just chose. Matches the - // knowledge-base move below and both Tables handlers. + // move in between would otherwise skip the write the user just chose. const current = foldersRef.current.find((item) => item.id === folder.id) ?? folder if ((current.parentId ?? null) !== parentId) await moveFolderTo(folder.id, parentId) closeFolderContextMenu() @@ -869,8 +925,7 @@ export function Knowledge() { const kb = activeKnowledgeBaseRef.current if (!kb) return const folderId = parseMoveOptionValue(optionValue) - // Re-read placement from the live list: `activeKnowledgeBase` is a snapshot from when - // the menu opened, and a refetch since then would make the no-op check wrong. + // Same reasoning as `handleMoveFolder`: compare against the live row, not the snapshot. const current = knowledgeBasesRef.current.find((item) => item.id === kb.id) ?? kb if ((current.folderId ?? null) !== folderId) await moveKnowledgeBaseTo(kb.id, folderId) closeRowContextMenu() @@ -878,6 +933,103 @@ export function Knowledge() { [moveKnowledgeBaseTo, closeRowContextMenu] ) + /** + * The one move path for every multi-row gesture — dropping a selection onto a folder row and + * the action bar's "Move to" menu both land here, so a mixed selection of knowledge bases and + * folders commits as a single operation instead of one request per row. + */ + const moveRowsTo = useCallback( + (rows: { knowledgeBaseIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { + if (rows.knowledgeBaseIds.length === 0 && rows.folderIds.length === 0) return + bulkMoveKnowledgeBases.mutate( + { ...rows, targetFolderId }, + { + onSuccess: (result) => { + clearSelection() + reportBulkOutcome(result, 'moved') + }, + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [clearSelection] + ) + + const handleBulkMove = useCallback( + (optionValue: string) => { + moveRowsTo( + { knowledgeBaseIds: selectedKnowledgeBaseIds, folderIds: selectedFolderIds }, + parseMoveOptionValue(optionValue) + ) + }, + [moveRowsTo, selectedKnowledgeBaseIds, selectedFolderIds] + ) + + const handleBulkDelete = useCallback(() => { + if (selectedKnowledgeBaseIds.length === 0 && selectedFolderIds.length === 0) return + setIsBulkDeleteModalOpen(true) + }, [selectedKnowledgeBaseIds, selectedFolderIds]) + + const confirmBulkDelete = useCallback(async () => { + try { + const result = await bulkDeleteKnowledgeBases.mutateAsync({ + knowledgeBaseIds: selectedKnowledgeBaseIds, + folderIds: selectedFolderIds, + }) + setIsBulkDeleteModalOpen(false) + clearSelection() + reportBulkOutcome(result, 'deleted') + } catch (deleteError) { + // The mutation toasts the request failure itself; the modal stays open to allow a retry. + logger.error('Failed to delete selected items', deleteError) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + }, [selectedKnowledgeBaseIds, selectedFolderIds, clearSelection]) + + /** + * Destinations for the action bar's move menu. Every selected folder — and everything beneath + * it — is excluded, since a folder cannot be filed into itself or its own subtree. + */ + const bulkMoveOptions: MoveOptionNode[] = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_BREADCRUMB_LABEL, + excludeFolderIds: selectedFolderIds, + descendantsByFolderId, + }), + [selectedFolderIds, folders, descendantsByFolderId] + ) + + const activeMoveOptions = hasMultiSelection ? bulkMoveOptions : knowledgeBaseMoveOptions + const activeFolderMoveOptions = hasMultiSelection ? bulkMoveOptions : folderMoveOptions + + const handleDeleteFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) return handleBulkDelete() + return handleDelete() + }, [handleBulkDelete, handleDelete]) + + const handleFolderDeleteFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) return handleBulkDelete() + return handleRequestFolderDelete() + }, [handleBulkDelete, handleRequestFolderDelete]) + + const handleMoveKnowledgeBaseFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveKnowledgeBase(optionValue) + }, + [handleBulkMove, handleMoveKnowledgeBase] + ) + + const handleMoveFolderFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveFolder(optionValue) + }, + [handleBulkMove, handleMoveFolder] + ) + const rowDragDropConfig = useFolderRowDragDrop({ canEdit, editingRowId: listRename.editingId, @@ -891,9 +1043,10 @@ export function Knowledge() { ? (foldersRef.current.find((f) => f.id === parsed.id)?.name ?? 'Folder') : (knowledgeBasesRef.current.find((kb) => kb.id === parsed.id)?.name ?? 'Knowledge base') }, - onMoveFolder: (folderId, targetFolderId) => void moveFolderTo(folderId, targetFolderId), - onMoveResource: (knowledgeBaseId, targetFolderId) => - void moveKnowledgeBaseTo(knowledgeBaseId, targetFolderId), + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => + moveRowsTo({ folderIds, knowledgeBaseIds: resourceIds }, targetFolderId), + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: setCurrentFolderId, }) const headerActions: ResourceAction[] = useMemo( @@ -996,18 +1149,7 @@ export function Knowledge() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) @@ -1089,6 +1231,35 @@ export function Knowledge() { [connectorFilter, contentFilter, ownerFilter, memberOptions] ) + /** Stable identity so the memoized `Resource.Options` can bail; an inline object cannot. */ + const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) + + /** + * Memoized element, not inline JSX: `Resource.Table` is `memo`'d, and a fresh overlay element + * every render would fail its shallow compare and re-render the whole list on any parent + * render — during an upload or a drag, that is every frame. + */ + const actionBar = useMemo( + () => ( + + ), + [ + selectedRowIds.size, + canEdit, + handleBulkMove, + bulkMoveOptions, + handleBulkDelete, + bulkMoveKnowledgeBases.isPending, + bulkDeleteKnowledgeBases.isPending, + ] + ) + const filterTags: FilterTag[] = useMemo(() => { const tags: FilterTag[] = [] if (connectorFilter.length > 0) { @@ -1128,14 +1299,16 @@ export function Knowledge() { search={searchConfig} sort={sortConfig} filterTags={filterTags} - filter={{ content: filterContent }} + filter={filterConfig} /> @@ -1160,9 +1333,9 @@ export function Knowledge() { onTogglePin={handleToggleBasePin} pinned={pinnedBaseIds.has(activeKnowledgeBase.id)} onEdit={handleEdit} - onDelete={handleDelete} - onMove={handleMoveKnowledgeBase} - moveOptions={knowledgeBaseMoveOptions} + onDelete={handleDeleteFromMenu} + onMove={handleMoveKnowledgeBaseFromMenu} + moveOptions={activeMoveOptions} showOpenInNewTab showViewTags showEdit @@ -1179,12 +1352,12 @@ export function Knowledge() { onClose={closeFolderContextMenu} onOpen={handleOpenFolder} onRename={handleRenameFolder} - onDelete={handleRequestFolderDelete} + onDelete={handleFolderDeleteFromMenu} onCopyId={handleCopyFolderId} onTogglePin={handleToggleFolderPin} pinned={pinnedFolderIds.has(activeFolder.id)} - onMove={handleMoveFolder} - moveOptions={folderMoveOptions} + onMove={handleMoveFolderFromMenu} + moveOptions={activeFolderMoveOptions} canEdit={canEdit} /> )} @@ -1209,6 +1382,26 @@ export function Knowledge() { }} /> + 0 + ? '? This also deletes the knowledge bases and folders inside the selected folders. You can restore them from Recently Deleted in Settings.' + : '? You can restore them from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: confirmBulkDelete, + pending: bulkDeleteKnowledgeBases.isPending, + pendingLabel: 'Deleting...', + }} + /> + {activeKnowledgeBase && ( )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index cb2f0fdd6b7..8ab4cf3c53c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -22,9 +22,14 @@ import type { } from '@/app/workspace/[workspaceId]/components' import { EMPTY_CELL_PLACEHOLDER, + FILTER_SECTION_LABEL_CLASS, + OwnerAvatar, ownerCell, Resource, + reportBulkOutcome, + selectionLabel, timeCell, + useResourceRowSelection, } from '@/app/workspace/[workspaceId]/components' import type { MoveOptionNode, @@ -33,6 +38,7 @@ import type { import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, FOLDERED_RESOURCE_HEADERS, FolderContextMenu, folderBreadcrumbItems, @@ -42,9 +48,11 @@ import { parseFolderedRowId, parseMoveOptionValue, sortResources, + splitFolderedRowIds, useFolderNavigation, useFolderRowDragDrop, } from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceActionBar } from '@/app/workspace/[workspaceId]/components/resource/components/action-bar' import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' import { @@ -64,6 +72,8 @@ import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hoo import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items' import { exportTable, + useBulkDeleteTables, + useBulkMoveTables, useCreateTable, useDeleteTable, useImportCsv, @@ -154,6 +164,8 @@ export function Tables() { const renameTable = useRenameTable(workspaceId) const createTable = useCreateTable(workspaceId) const moveTable = useMoveTable(workspaceId) + const bulkMoveTables = useBulkMoveTables(workspaceId) + const bulkDeleteTables = useBulkDeleteTables(workspaceId) const importCsv = useImportCsv() const createFolder = useCreateFolder() const updateFolder = useUpdateFolder() @@ -203,6 +215,7 @@ export function Tables() { const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleteFolderDialogOpen, setIsDeleteFolderDialogOpen] = useState(false) + const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false) const [isImportDialogOpen, setIsImportDialogOpen] = useState(false) const [activeTable, setActiveTable] = useState(null) const [activeFolder, setActiveFolder] = useState(null) @@ -258,7 +271,8 @@ export function Tables() { closeMenu: closeRowContextMenu, } = useContextMenu() - const [contextMenuKind, setContextMenuKind] = useState<'table' | 'folder'>('table') + /** Which row kind the row context menu acts on — whichever active slot the handler filled. */ + const contextMenuKind: 'table' | 'folder' = activeFolder ? 'folder' : 'table' /** * Descendants of every folder, so a move destination that sits inside the moved folder can @@ -459,6 +473,33 @@ export function Tables() { [listRename.startRename] ) + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) + + const { + selectedRowIds, + selectable: selectableConfig, + replaceSelection, + clearSelection, + } = useResourceRowSelection({ + visibleRowIds, + isKeyboardBlocked: () => !canEdit || listRename.editingId !== null, + onDeleteSelected: () => handleBulkDelete(), + }) + + const { folderIds: selectedFolderIds, resourceIds: selectedTableIds } = useMemo( + () => splitFolderedRowIds(selectedRowIds), + [selectedRowIds] + ) + + const bulkDeleteLabel = useMemo(() => { + const count = selectedTableIds.length + selectedFolderIds.length + const firstName = + selectedTableIds.length > 0 + ? tables.find((table) => table.id === selectedTableIds[0])?.name + : folderById.get(selectedFolderIds[0])?.name + return selectionLabel(count, firstName) + }, [selectedTableIds, selectedFolderIds, tables, folderById]) + const currentFolderActions: DropdownOption[] | undefined = useMemo(() => { if (!currentFolderId) return undefined const folder = folderById.get(currentFolderId) @@ -570,18 +611,7 @@ export function Tables() { (members ?? []).map((m) => ({ value: m.userId, label: m.name, - iconElement: m.image ? ( - {m.name} - ) : ( - - {m.name.charAt(0).toUpperCase()} - - ), + iconElement: , })), [members] ) @@ -592,7 +622,7 @@ export function Tables() { () => (
- Row Count + Row Count {memberOptions.length > 0 && (
- Owner + Owner { const item = resolveRowItem(rowId) if (!item) return + /** + * Right-clicking outside the selection retargets it, so the menu always acts on what is + * highlighted. Right-clicking inside it leaves the selection alone and the menu switches + * its move/delete entries to the bulk handlers. + */ + if (canEdit && !selectedRowIds.has(rowId)) replaceSelection([rowId]) if (item.kind === 'folder') { setActiveFolder(item.folder) setActiveTable(null) - setContextMenuKind('folder') } else { setActiveTable(item.table) setActiveFolder(null) - setContextMenuKind('table') } handleRowCtxMenu(e) }, - [resolveRowItem, handleRowCtxMenu] + [resolveRowItem, handleRowCtxMenu, canEdit, selectedRowIds, replaceSelection] ) + /** A multi-row selection retargets the row context menu's move and delete entries. */ const tableMoveOptions: MoveOptionNode[] = useMemo( () => buildMoveOptions({ folders, rootLabel: ROOT_LABEL }), [folders] ) - const folderMoveOptions: MoveOptionNode[] = useMemo(() => { - if (!activeFolder) return [] - const excluded = new Set([activeFolder.id]) - for (const id of descendantFolderIds.get(activeFolder.id) ?? []) excluded.add(id) - return buildMoveOptions({ folders, rootLabel: ROOT_LABEL, excludedFolderIds: excluded }) - }, [activeFolder, folders, descendantFolderIds]) + const folderMoveOptions: MoveOptionNode[] = useMemo( + () => + activeFolder + ? buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_LABEL, + excludeFolderIds: [activeFolder.id], + descendantsByFolderId: descendantFolderIds, + }) + : [], + [activeFolder, folders, descendantFolderIds] + ) + + /** + * Destinations for the action bar's move menu. Every selected folder — and everything beneath + * it — is excluded, since a folder cannot be filed into itself or its own subtree. + */ + const bulkMoveOptions: MoveOptionNode[] = useMemo( + () => + buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: ROOT_LABEL, + excludeFolderIds: selectedFolderIds, + descendantsByFolderId: descendantFolderIds, + }), + [selectedFolderIds, folders, descendantFolderIds] + ) const handleMoveTable = useCallback( (optionValue: string) => { @@ -753,7 +809,7 @@ export function Tables() { * Placement is re-read from the live list rather than trusted from `activeTable`, which * is a snapshot taken when the menu opened. A refetch or a concurrent move since then * would make the no-op check compare against a stale location and skip a write the user - * asked for. Matches the knowledge-base move. + * asked for. */ const current = tablesRef.current.find((table) => table.id === activeTable.id) ?? activeTable if ((current.folderId ?? null) === folderId) { @@ -794,6 +850,102 @@ export function Tables() { [activeFolder, folderById, moveFolderTo, closeRowContextMenu] ) + /** + * The one move path for every multi-row gesture — dropping a selection onto a folder row and + * the action bar's "Move to" menu both land here, so a mixed selection of tables and folders + * commits as a single operation instead of one request per row. + */ + const moveRowsTo = useCallback( + (rows: { tableIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { + if (rows.tableIds.length === 0 && rows.folderIds.length === 0) return + bulkMoveTables.mutate( + { ...rows, targetFolderId }, + { + onSuccess: (result) => { + clearSelection() + reportBulkOutcome(result, 'moved') + }, + } + ) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutate is stable in v5 + [clearSelection] + ) + + const handleBulkMove = useCallback( + (optionValue: string) => { + moveRowsTo( + { tableIds: selectedTableIds, folderIds: selectedFolderIds }, + parseMoveOptionValue(optionValue) + ) + }, + [moveRowsTo, selectedTableIds, selectedFolderIds] + ) + + const handleBulkDelete = useCallback(() => { + if (selectedTableIds.length === 0 && selectedFolderIds.length === 0) return + setIsBulkDeleteDialogOpen(true) + }, [selectedTableIds, selectedFolderIds]) + + const confirmBulkDelete = useCallback(async () => { + try { + const result = await bulkDeleteTables.mutateAsync({ + tableIds: selectedTableIds, + folderIds: selectedFolderIds, + }) + setIsBulkDeleteDialogOpen(false) + clearSelection() + reportBulkOutcome(result, 'deleted') + } catch (err) { + // The mutation toasts the request failure itself; the modal stays open to allow a retry. + logger.error('Failed to delete selected items:', err) + } + // eslint-disable-next-line react-hooks/exhaustive-deps -- mutation objects are unstable; mutateAsync is stable in v5 + }, [selectedTableIds, selectedFolderIds, clearSelection]) + + /** + * A context menu opened on a multi-row selection acts on the whole selection. Resolved inside + * these handlers rather than at each menu prop, so the menus stay unaware selection exists. + */ + const hasMultiSelection = selectedRowIds.size > 1 + const hasMultiSelectionRef = useRef(hasMultiSelection) + hasMultiSelectionRef.current = hasMultiSelection + + const activeMoveOptions = hasMultiSelection ? bulkMoveOptions : tableMoveOptions + const activeFolderMoveOptions = hasMultiSelection ? bulkMoveOptions : folderMoveOptions + + const handleMoveTableFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveTable(optionValue) + }, + [handleBulkMove, handleMoveTable] + ) + + const handleMoveFolderFromMenu = useCallback( + (optionValue: string) => { + if (hasMultiSelectionRef.current) return handleBulkMove(optionValue) + return handleMoveFolder(optionValue) + }, + [handleBulkMove, handleMoveFolder] + ) + + const handleDeleteTableFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) { + handleBulkDelete() + return + } + setIsDeleteDialogOpen(true) + }, [handleBulkDelete]) + + const handleDeleteFolderFromMenu = useCallback(() => { + if (hasMultiSelectionRef.current) { + handleBulkDelete() + return + } + setIsDeleteFolderDialogOpen(true) + }, [handleBulkDelete]) + const rowDragDropConfig = useFolderRowDragDrop({ canEdit, editingRowId: listRename.editingId, @@ -807,9 +959,10 @@ export function Tables() { ? (folderById.get(parsed.id)?.name ?? 'Folder') : (tablesRef.current.find((table) => table.id === parsed.id)?.name ?? 'Table') }, - onMoveFolder: (folderId, targetFolderId) => moveFolderTo(folderId, targetFolderId), - onMoveResource: (tableId, targetFolderId) => - moveTable.mutate({ tableId, folderId: targetFolderId }), + onMoveRows: ({ folderIds, resourceIds }, targetFolderId) => + moveRowsTo({ folderIds, tableIds: resourceIds }, targetFolderId), + selection: { selectedRowIds, visibleRowIds, replaceSelection }, + onSpringOpenFolder: setCurrentFolderId, }) const handleDelete = async () => { @@ -1027,9 +1180,30 @@ export function Tables() { ] ) - // Stable identities so the memoized Resource.Header / Resource.Options can + // Stable identities so the memoized Resource.Header / Resource.Options / Resource.Table can // actually bail — inline object/element props would defeat their memo. const headerAside = useMemo(() => , [workspaceId]) + + const actionBar = useMemo( + () => ( + + ), + [ + selectedRowIds.size, + canEdit, + handleBulkMove, + bulkMoveOptions, + handleBulkDelete, + bulkMoveTables.isPending, + bulkDeleteTables.isPending, + ] + ) const filterConfig = useMemo(() => ({ content: filterContent }), [filterContent]) return ( @@ -1051,9 +1225,11 @@ export function Tables() { @@ -1086,7 +1262,7 @@ export function Tables() { onCopyId={() => { if (activeTable) navigator.clipboard.writeText(activeTable.id) }} - onDelete={() => setIsDeleteDialogOpen(true)} + onDelete={handleDeleteTableFromMenu} onRename={() => { if (activeTable) listRename.startRename(activeTable.id, activeTable.name) }} @@ -1103,8 +1279,8 @@ export function Tables() { }} onTogglePin={handleTogglePin} pinned={activeTable ? pinnedTableIds.has(activeTable.id) : false} - onMove={canEdit ? handleMoveTable : undefined} - moveOptions={canEdit ? tableMoveOptions : undefined} + onMove={canEdit ? handleMoveTableFromMenu : undefined} + moveOptions={canEdit ? activeMoveOptions : undefined} disableDelete={!canEdit} disableRename={!canEdit} disableImport={!canEdit} @@ -1124,11 +1300,11 @@ export function Tables() { onCopyId={() => { if (activeFolder) navigator.clipboard.writeText(activeFolder.id) }} - onDelete={() => setIsDeleteFolderDialogOpen(true)} + onDelete={handleDeleteFolderFromMenu} onTogglePin={handleTogglePin} pinned={activeFolder ? pinnedFolderIds.has(activeFolder.id) : false} - onMove={canEdit ? handleMoveFolder : undefined} - moveOptions={canEdit ? folderMoveOptions : undefined} + onMove={canEdit ? handleMoveFolderFromMenu : undefined} + moveOptions={canEdit ? activeFolderMoveOptions : undefined} canEdit={canEdit} /> @@ -1189,6 +1365,32 @@ export function Tables() { pendingLabel: 'Deleting...', }} /> + + 0 + ? 'Every table and subfolder inside the selected folders will be deleted too.' + : 'All of their rows will be removed.', + error: true, + }, + ' You can restore those tables from Recently Deleted in Settings.', + ]} + confirm={{ + label: 'Delete', + onClick: confirmBulkDelete, + pending: bulkDeleteTables.isPending, + pendingLabel: 'Deleting...', + }} + /> ) } diff --git a/apps/sim/hooks/queries/kb/knowledge.ts b/apps/sim/hooks/queries/kb/knowledge.ts index f46457282fc..5902e0d7be1 100644 --- a/apps/sim/hooks/queries/kb/knowledge.ts +++ b/apps/sim/hooks/queries/kb/knowledge.ts @@ -5,9 +5,13 @@ import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { type BulkChunkOperationData, + type BulkDeleteKnowledgeItemsBody, type BulkDocumentOperationData, + type BulkMoveKnowledgeItemsBody, + bulkDeleteKnowledgeItemsContract, bulkKnowledgeChunksContract, bulkKnowledgeDocumentsContract, + bulkMoveKnowledgeItemsContract, type ChunkData, type ChunksPagination, createKnowledgeBaseContract, @@ -47,6 +51,7 @@ import { } from '@/lib/api/contracts/knowledge' import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types' import type { DocumentSortField, SortOrder } from '@/lib/knowledge/documents/types' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { KNOWLEDGE_BASE_LIST_STALE_TIME, type KnowledgeQueryScope, @@ -1045,3 +1050,83 @@ export function useDeleteDocumentTagDefinitions() { }, }) } + +/** + * Move a mixed selection of knowledge bases and knowledge folders into one + * folder, or to the workspace root with `targetFolderId: null`. + * + * One request, one authorized operation: the Knowledge list interleaves folder + * and knowledge base rows in a single grid, so a selection is routinely mixed + * and must not be split into a resource call plus a per-folder fan-out. + * + * No optimistic patch, unlike the single-base move: a folder move re-parents + * rows the list renders at a different level, and the response reports per-item + * outcomes (`skipped`, `notFound`, `failed`) the client cannot predict. + */ +export function useBulkMoveKnowledgeBases(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + knowledgeBaseIds = [], + folderIds = [], + targetFolderId, + }: Omit) => { + const result = await requestJson(bulkMoveKnowledgeItemsContract, { + body: { workspaceId, knowledgeBaseIds, folderIds, targetFolderId }, + }) + return result.data + }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { knowledgeBaseIds = [] }) => { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('knowledge_base') }) + /** + * `exact` because `detail` is the prefix for the base's documents, chunks, and tag + * queries — a move only re-parents the base record itself, so a prefix invalidation would + * refetch every cached document and chunk page for nothing. The sibling delete hook + * deliberately stays non-exact, since there the children really must go. + */ + for (const knowledgeBaseId of knowledgeBaseIds) { + queryClient.invalidateQueries({ + queryKey: knowledgeKeys.detail(knowledgeBaseId), + exact: true, + }) + } + }, + }) +} + +/** + * Delete a mixed selection of knowledge bases and knowledge folders. + * + * Deleting a folder cascades to every knowledge base and subfolder inside it, + * so the response's `deletedItems` totals exceed the explicitly selected count. + */ +export function useBulkDeleteKnowledgeBases(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + knowledgeBaseIds = [], + folderIds = [], + }: Omit) => { + const result = await requestJson(bulkDeleteKnowledgeItemsContract, { + body: { workspaceId, knowledgeBaseIds, folderIds }, + }) + return result.data + }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { knowledgeBaseIds = [] }) => { + queryClient.invalidateQueries({ queryKey: knowledgeKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('knowledge_base') }) + for (const knowledgeBaseId of knowledgeBaseIds) { + queryClient.removeQueries({ queryKey: knowledgeKeys.detail(knowledgeBaseId) }) + } + }, + }) +} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 7423d6b09de..c03356af9d1 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -38,8 +38,12 @@ import { addWorkflowGroupContract, type BatchInsertTableRowsBodyInput, type BatchUpdateTableRowsBodyInput, + type BulkDeleteTablesBody, + type BulkMoveTablesBody, batchCreateTableRowsContract, batchUpdateTableRowsContract, + bulkDeleteTablesContract, + bulkMoveTablesContract, type CreateTableBodyInput, type CreateTableColumnBodyInput, cancelTableRunsContract, @@ -114,6 +118,7 @@ import { sanitizeName } from '@/lib/table/import' import type { UploadProgressEvent } from '@/lib/uploads/client/types' import { uploadFileSession } from '@/lib/uploads/client/upload-session' import { useTimezone } from '@/hooks/queries/general-settings' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { TABLE_LIST_STALE_TIME, TABLE_VIEWS_STALE_TIME, @@ -2510,3 +2515,81 @@ export function useDeleteWorkflowGroup({ workspaceId, tableId }: RowMutationCont }, }) } + +/** + * Move a mixed selection of tables and table folders into one folder, or to the + * workspace root with `targetFolderId: null`. + * + * One request, one authorized operation: the Tables list interleaves folder and + * table rows in a single grid, so a selection is routinely mixed and must not be + * split into a resource call plus a per-folder fan-out. + * + * No optimistic patch. A folder move re-parents rows that the list renders at a + * different level, and the response reports per-item outcomes (`skipped`, + * `notFound`, `failed`) the client cannot predict, so the caller reads the + * result rather than guessing it. + */ +export function useBulkMoveTables(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + tableIds = [], + folderIds = [], + targetFolderId, + }: Omit) => { + const result = await requestJson(bulkMoveTablesContract, { + body: { workspaceId, tableIds, folderIds, targetFolderId }, + }) + return result.data + }, + onError: (error) => { + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { tableIds = [] }) => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) + for (const tableId of tableIds) { + queryClient.invalidateQueries({ queryKey: tableKeys.detail(tableId), exact: true }) + } + }, + }) +} + +/** + * Archive a mixed selection of tables and table folders. + * + * Deleting a folder cascades to every table and subfolder inside it, so the + * response's `deletedItems` totals exceed the explicitly selected count. Cached + * detail and row entries are removed only for the tables named in the request — + * a cascaded table's detail cache is left to the list invalidation, since the + * request never named it. + */ +export function useBulkDeleteTables(workspaceId: string) { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ + tableIds = [], + folderIds = [], + }: Omit) => { + const result = await requestJson(bulkDeleteTablesContract, { + body: { workspaceId, tableIds, folderIds }, + }) + return result.data + }, + onError: (error) => { + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, + onSettled: (_data, _error, { tableIds = [] }) => { + queryClient.invalidateQueries({ queryKey: tableKeys.lists() }) + queryClient.invalidateQueries({ queryKey: folderKeys.resource('table') }) + for (const tableId of tableIds) { + queryClient.removeQueries({ queryKey: tableKeys.detail(tableId) }) + queryClient.removeQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + } + }, + }) +} diff --git a/apps/sim/lib/api/contracts/knowledge/base.ts b/apps/sim/lib/api/contracts/knowledge/base.ts index 475c31e737e..2998aa83b26 100644 --- a/apps/sim/lib/api/contracts/knowledge/base.ts +++ b/apps/sim/lib/api/contracts/knowledge/base.ts @@ -5,11 +5,17 @@ import { successResponseSchema, wireDateSchema, } from '@/lib/api/contracts/knowledge/shared' +import { + folderIdSchema, + requiredFieldSchema, + workspaceIdSchema, +} from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' import type { StrategyOptions } from '@/lib/chunkers/types' import { DEFAULT_CHUNKING_CONFIG, KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH, + MAX_KNOWLEDGE_BATCH_ITEMS, } from '@/lib/knowledge/constants' export const knowledgeScopeSchema = z.enum(['active', 'archived', 'all']) @@ -195,3 +201,127 @@ export const restoreKnowledgeBaseContract = defineRouteContract({ schema: z.object({ success: z.literal(true) }).passthrough(), }, }) + +const bulkKnowledgeIdListSchema = z + .array(requiredFieldSchema('id entries cannot be empty')) + .max(MAX_KNOWLEDGE_BATCH_ITEMS, `cannot contain more than ${MAX_KNOWLEDGE_BATCH_ITEMS} ids`) + .default([]) + +/** + * Bounds a mixed selection from the Knowledge list, which interleaves folder + * rows and knowledge base rows in one grid. Both lists travel in one request so + * a mixed selection commits as one authorized operation instead of a + * client-sequenced fan-out. + * + * The cap is on the combined count: each list is individually bounded first so + * an oversized array is rejected before the combined arithmetic, and folders + * cost more than bases because they cascade. + */ +function refineBoundedKnowledgeSelection( + selection: { knowledgeBaseIds: string[]; folderIds: string[] }, + ctx: z.RefinementCtx +): void { + const total = selection.knowledgeBaseIds.length + selection.folderIds.length + if (total === 0) { + ctx.addIssue({ + code: 'custom', + path: ['knowledgeBaseIds'], + message: 'At least one knowledge base or folder must be selected', + }) + return + } + if (total > MAX_KNOWLEDGE_BATCH_ITEMS) { + ctx.addIssue({ + code: 'custom', + path: ['knowledgeBaseIds'], + message: `knowledgeBaseIds and folderIds cannot contain more than ${MAX_KNOWLEDGE_BATCH_ITEMS} ids combined`, + }) + } +} + +export const bulkMoveKnowledgeItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + knowledgeBaseIds: bulkKnowledgeIdListSchema, + folderIds: bulkKnowledgeIdListSchema, + /** Destination folder in the `knowledge_base` tree. `null` is the workspace root. */ + targetFolderId: folderIdSchema.nullable(), + }) + .superRefine(refineBoundedKnowledgeSelection) +export type BulkMoveKnowledgeItemsBody = z.input + +export const bulkDeleteKnowledgeItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + knowledgeBaseIds: bulkKnowledgeIdListSchema, + /** Folders to delete. Each cascades to every knowledge base and subfolder inside it. */ + folderIds: bulkKnowledgeIdListSchema, + }) + .superRefine(refineBoundedKnowledgeSelection) +export type BulkDeleteKnowledgeItemsBody = z.input + +const bulkKnowledgeItemKindSchema = z.enum(['knowledgeBase', 'folder']) + +const bulkKnowledgeItemSchema = z.object({ + kind: bulkKnowledgeItemKindSchema, + id: z.string(), + name: z.string(), +}) + +/** An id nothing active resolved to. Carries no name, because nothing was found to name. */ +const bulkKnowledgeMissingSchema = z.object({ + kind: bulkKnowledgeItemKindSchema, + id: z.string(), +}) + +/** + * An item the batch reached but could not act on for a reason the caller can + * act on in turn. Distinct from `notFound`, which also absorbs the items the + * caller may not write to. + */ +const bulkKnowledgeFailureSchema = bulkKnowledgeItemSchema.extend({ reason: z.string() }) + +/** + * Items dropped because a selected folder already carries them: a knowledge + * base filed inside a selected folder, or a subfolder of another selected one. + */ +const bulkKnowledgeSkippedSchema = z.array(bulkKnowledgeItemSchema) + +export const bulkMoveKnowledgeItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/bulk-move', + body: bulkMoveKnowledgeItemsBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + moved: z.array(bulkKnowledgeItemSchema), + skipped: bulkKnowledgeSkippedSchema, + notFound: z.array(bulkKnowledgeMissingSchema), + failed: z.array(bulkKnowledgeFailureSchema), + }) + ), + }, +}) + +export const bulkDeleteKnowledgeItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/knowledge/bulk-delete', + body: bulkDeleteKnowledgeItemsBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + deleted: z.array(bulkKnowledgeItemSchema), + skipped: bulkKnowledgeSkippedSchema, + notFound: z.array(bulkKnowledgeMissingSchema), + failed: z.array(bulkKnowledgeFailureSchema), + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: z.object({ + knowledgeBases: z.number().int(), + folders: z.number().int(), + }), + }) + ), + }, +}) diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 1f98a6cbfaa..24f4bc8037d 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -32,6 +32,7 @@ import { FILTER_OPS, MAX_RUN_TARGET_ROW_IDS, MAX_SELECT_OPTIONS, + MAX_TABLE_BATCH_ITEMS, NAME_PATTERN, SORT_DIRECTIONS, TABLE_LIMITS, @@ -2209,3 +2210,125 @@ export type TableViewWire = z.output export type TableViewConfigInput = z.input export type CreateTableViewBody = z.input export type UpdateTableViewBody = z.input + +const bulkTableIdListSchema = z + .array(requiredFieldSchema('id entries cannot be empty')) + .max(MAX_TABLE_BATCH_ITEMS, `cannot contain more than ${MAX_TABLE_BATCH_ITEMS} ids`) + .default([]) + +/** + * Bounds a mixed selection from the Tables list, which interleaves folder rows + * and table rows in one grid. Both lists travel in one request so a mixed + * selection commits as one authorized operation instead of a client-sequenced + * fan-out. + * + * The cap is on the combined count: each list is individually bounded first so + * a 10,000-entry array is rejected before the combined arithmetic, and folders + * cost more than tables because they cascade. + */ +function refineBoundedTableSelection( + selection: { tableIds: string[]; folderIds: string[] }, + ctx: z.RefinementCtx +): void { + const total = selection.tableIds.length + selection.folderIds.length + if (total === 0) { + ctx.addIssue({ + code: 'custom', + path: ['tableIds'], + message: 'At least one table or folder must be selected', + }) + return + } + if (total > MAX_TABLE_BATCH_ITEMS) { + ctx.addIssue({ + code: 'custom', + path: ['tableIds'], + message: `tableIds and folderIds cannot contain more than ${MAX_TABLE_BATCH_ITEMS} ids combined`, + }) + } +} + +export const bulkMoveTablesBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns every selected item.'), + tableIds: bulkTableIdListSchema.describe('Tables to move, by identifier.'), + folderIds: bulkTableIdListSchema.describe('Table folders to re-parent, by identifier.'), + targetFolderId: folderIdSchema + .nullable() + .describe('Destination folder in the table folder tree. `null` is the workspace root.'), + }) + .superRefine(refineBoundedTableSelection) +export type BulkMoveTablesBody = z.input + +export const bulkDeleteTablesBodySchema = z + .object({ + workspaceId: workspaceIdSchema.describe('Workspace that owns every selected item.'), + tableIds: bulkTableIdListSchema.describe('Tables to archive, by identifier.'), + folderIds: bulkTableIdListSchema.describe( + 'Table folders to delete, by identifier. Each cascades to everything inside it.' + ), + }) + .superRefine(refineBoundedTableSelection) +export type BulkDeleteTablesBody = z.input + +const bulkTableItemKindSchema = z.enum(['table', 'folder']) + +const bulkTableItemSchema = z.object({ + kind: bulkTableItemKindSchema, + id: z.string(), + name: z.string(), +}) + +/** An id nothing active resolved to. Carries no name, because nothing was found to name. */ +const bulkTableMissingSchema = z.object({ kind: bulkTableItemKindSchema, id: z.string() }) + +/** + * An item the batch reached but could not act on for a reason the caller can + * act on in turn — a delete lock, a folder cycle. Distinct from `notFound`, + * which also absorbs the items the caller may not write to. + */ +const bulkTableFailureSchema = bulkTableItemSchema.extend({ reason: z.string() }) + +/** + * Items dropped because a selected folder already carries them: a table filed + * inside a selected folder, or a subfolder of another selected folder. + */ +const bulkTableSkippedSchema = z.array(bulkTableItemSchema) + +export const bulkMoveTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/table/bulk-move', + body: bulkMoveTablesBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + moved: z.array(bulkTableItemSchema), + skipped: bulkTableSkippedSchema, + notFound: z.array(bulkTableMissingSchema), + failed: z.array(bulkTableFailureSchema), + }) + ), + }, +}) +export const bulkDeleteTablesContract = defineRouteContract({ + method: 'POST', + path: '/api/table/bulk-delete', + body: bulkDeleteTablesBodySchema, + response: { + mode: 'json', + schema: successResponseSchema( + z.object({ + deleted: z.array(bulkTableItemSchema), + skipped: bulkTableSkippedSchema, + notFound: z.array(bulkTableMissingSchema), + failed: z.array(bulkTableFailureSchema), + /** Totals across the explicit archives and every folder cascade they triggered. */ + deletedItems: z.object({ + tables: z.number().int(), + folders: z.number().int(), + }), + }) + ), + }, +}) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index 90115a49ce0..1695c5aa3e1 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -20,7 +20,6 @@ import { import { asOrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { addWorkspaceFilesToKnowledgeBase } from '@/lib/knowledge/application/add-workspace-files' -import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/application/batch-policy' import { KnowledgeUsageLimitExceededError } from '@/lib/knowledge/application/billing' import { createKnowledgeConnector, @@ -46,7 +45,10 @@ import { readKnowledgeTagUsage, updateKnowledgeTag, } from '@/lib/knowledge/application/tags' -import { KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH } from '@/lib/knowledge/constants' +import { + KNOWLEDGE_TAG_DISPLAY_NAME_MAX_LENGTH, + MAX_KNOWLEDGE_BATCH_ITEMS, +} from '@/lib/knowledge/constants' import { captureServerEvent } from '@/lib/posthog/server' import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection' diff --git a/apps/sim/lib/core/application/batch-policy.ts b/apps/sim/lib/core/application/batch-policy.ts new file mode 100644 index 00000000000..dc1eab9d420 --- /dev/null +++ b/apps/sim/lib/core/application/batch-policy.ts @@ -0,0 +1,61 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' + +/** The failure that ended a `sequential_best_effort` batch early. */ +export interface BatchTerminalFailure { + error: unknown +} + +export interface BatchExecutionResult { + terminalFailure?: BatchTerminalFailure +} + +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export function rethrowBatchTerminalFailure(result: BatchExecutionResult): void { + if (result.terminalFailure) throw result.terminalFailure.error +} + +/** A deduplicated, bounded mixed selection of resources and folders. */ +export interface BoundedResourceSelection { + resourceIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed resource/folder selection before any + * protected row is loaded. + * + * The cap is on the **combined** count, not on each list: a request naming 100 + * resources and 100 folders costs twice what the ceiling is meant to allow, and + * a folder costs more than a resource because it cascades. + * + * Returns neutral key names; each domain renames them so its own selection type + * stays self-describing. + */ +export function requireBoundedResourceSelection( + resourceIds: readonly string[], + folderIds: readonly string[], + maxItems: number, + noun: { singular: string; plural: string } +): BoundedResourceSelection { + const selection = { + resourceIds: [...new Set(resourceIds)], + folderIds: [...new Set(folderIds)], + } + const total = selection.resourceIds.length + selection.folderIds.length + if (total === 0) { + throw new OrchestrationError( + 'validation', + `At least one ${noun.singular} or folder is required` + ) + } + if (total > maxItems) { + throw new OrchestrationError( + 'validation', + `Too many items (${total}). Maximum is ${maxItems} ${noun.plural} and folders combined.` + ) + } + return selection +} diff --git a/apps/sim/lib/core/application/bulk-items.test.ts b/apps/sim/lib/core/application/bulk-items.test.ts new file mode 100644 index 00000000000..60338c9aa6d --- /dev/null +++ b/apps/sim/lib/core/application/bulk-items.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { type BulkItemDisposition, classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +describe('classifyBulkItemError', () => { + /** + * The invariant this function exists to hold: an id the caller may not reach + * and an id that does not exist must be indistinguishable in the response, + * or a bulk request becomes a membership oracle for a workspace the caller + * can read but not write to. + */ + it.each(['not_found', 'forbidden', 'unauthorized'] as const)( + 'conceals %s as notFound, carrying no message', + (code) => { + const disposition = classifyBulkItemError( + new OrchestrationError(code, `secret detail for ${code}`) + ) + + expect(disposition).toEqual({ kind: 'notFound' }) + } + ) + + it('reports any other classified code as an actionable per-item failure', () => { + expect( + classifyBulkItemError(new OrchestrationError('validation', 'Bad target folder')) + ).toEqual({ kind: 'failed', reason: 'Bad target folder' }) + expect(classifyBulkItemError(new OrchestrationError('conflict', 'Name taken'))).toEqual({ + kind: 'failed', + reason: 'Name taken', + }) + }) + + it('ends the batch on an internal or unclassified error', () => { + const internal = new OrchestrationError('internal', 'connection reset') + expect(classifyBulkItemError(internal)).toEqual({ kind: 'terminal', error: internal }) + + const unclassified = new Error('socket hang up') + expect(classifyBulkItemError(unclassified)).toEqual({ + kind: 'terminal', + error: unclassified, + }) + }) + + it('lets a domain verdict rule on an error the shared classification cannot see', () => { + class DomainLockError extends Error {} + const verdict = (error: unknown): BulkItemDisposition | undefined => + error instanceof DomainLockError ? { kind: 'failed', reason: error.message } : undefined + + expect(classifyBulkItemError(new DomainLockError('Table is locked'), verdict)).toEqual({ + kind: 'failed', + reason: 'Table is locked', + }) + // Without the verdict the same error is an unclassified fault that ends the batch. + expect(classifyBulkItemError(new DomainLockError('Table is locked'))).toMatchObject({ + kind: 'terminal', + }) + }) + + /** + * A verdict must not be able to reopen the probe the concealment closes: the + * concealed codes are decided before the hook could widen them. + */ + it('keeps concealment ahead of a domain verdict that would widen it', () => { + const leakyVerdict = (error: unknown): BulkItemDisposition => ({ + kind: 'failed', + reason: `leaked: ${(error as Error).message}`, + }) + + for (const code of ['not_found', 'forbidden', 'unauthorized'] as const) { + expect( + classifyBulkItemError(new OrchestrationError(code, 'no write access'), leakyVerdict) + ).toEqual({ kind: 'notFound' }) + } + }) +}) diff --git a/apps/sim/lib/core/application/bulk-items.ts b/apps/sim/lib/core/application/bulk-items.ts new file mode 100644 index 00000000000..65c10ec58cf --- /dev/null +++ b/apps/sim/lib/core/application/bulk-items.ts @@ -0,0 +1,58 @@ +import { asOrchestrationError } from '@/lib/core/orchestration/types' + +/** + * Outcome of one item in a best-effort batch. + * + * `not_found`, `forbidden`, and `unauthorized` collapse into `notFound` so a + * caller cannot use a bulk request to probe which identifiers exist in a + * workspace it can see but may not write to — the same concealment the + * single-item operations apply. Anything the orchestration layer did not + * classify (or classified `internal`) ends the batch: it is an infrastructure + * failure, not a per-item verdict. + */ +export type BulkItemDisposition = + | { kind: 'notFound' } + | { kind: 'failed'; reason: string } + | { kind: 'terminal'; error: unknown } + +/** + * A domain's chance to rule on an error the shared classification cannot see. + * Return `undefined` to defer to the shared rules. + * + * Only for errors that are genuinely a per-item verdict and carry no + * orchestration code — a table's delete lock is the motivating case. A hook + * must never widen `notFound` into a distinguishable outcome, or it reopens the + * probe the concealment closes. + */ +export type BulkItemVerdict = (error: unknown) => BulkItemDisposition | undefined + +/** + * Classifies one item's error in a best-effort batch. One implementation for + * every domain, so the concealment rule above cannot drift between them. + */ +export function classifyBulkItemError( + error: unknown, + verdict?: BulkItemVerdict +): BulkItemDisposition { + const classified = asOrchestrationError(error) + /** + * Concealment is decided BEFORE the domain hook runs, so no hook can widen a + * concealed code back into a distinguishable outcome. A hook exists for + * errors the orchestration layer never classified at all. + */ + if ( + classified?.code === 'not_found' || + classified?.code === 'forbidden' || + classified?.code === 'unauthorized' + ) { + return { kind: 'notFound' } + } + + const domainVerdict = verdict?.(error) + if (domainVerdict) return domainVerdict + + if (classified && classified.code !== 'internal') { + return { kind: 'failed', reason: classified.message } + } + return { kind: 'terminal', error } +} diff --git a/apps/sim/lib/folders/bulk.ts b/apps/sim/lib/folders/bulk.ts new file mode 100644 index 00000000000..cac642104fe --- /dev/null +++ b/apps/sim/lib/folders/bulk.ts @@ -0,0 +1,277 @@ +import { createLogger } from '@sim/logger' +import type { FolderResourceType } from '@/lib/api/contracts/folders' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { deleteFolder, updateFolder } from '@/lib/folders/orchestration' +import { listActiveFolderRows } from '@/lib/folders/queries' +import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree' +import { notifyFolderResourceChanged } from '@/lib/realtime/notify' + +const logger = createLogger('FolderBulk') + +export interface BulkFolderAffected { + id: string + name: string +} + +export interface BulkFolderFailure extends BulkFolderAffected { + reason: string +} + +export interface FolderSelectionPlan { + /** Selected folders that resolve to an active folder of this resource type, request order preserved. */ + selected: BulkFolderAffected[] + /** Selected ids with no active folder of this resource type in the workspace. */ + notFound: string[] + /** + * Selected folders that sit inside another selected folder. They travel with + * their ancestor — moving or deleting them again would either rip a subfolder + * out of the parent it is moving with, or archive it under a second + * timestamp that the parent's restore could never bring back. + */ + contained: BulkFolderAffected[] + /** + * Every folder id the selection covers, including descendants. A resource + * filed in one of these is already handled by its folder and must not be + * acted on a second time. + */ + covered: Set +} + +/** + * Resolves a bulk folder selection against the workspace's live folder tree + * once, before anything is written. + * + * Reads the whole active tree in a single query rather than one lookup per + * selected id: the containment questions ("is this folder inside another + * selected one", "is this resource inside a selected folder") need the tree + * anyway, and a workspace's folder count is already bounded. + */ +export async function planFolderSelection( + workspaceId: string, + resourceType: FolderResourceType, + folderIds: readonly string[] +): Promise { + if (folderIds.length === 0) { + return { selected: [], notFound: [], contained: [], covered: new Set() } + } + + const rows = await listActiveFolderRows(workspaceId, resourceType, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const rowsById = new Map(rows.map((row) => [row.id, row])) + + const selected: BulkFolderAffected[] = [] + const notFound: string[] = [] + const contained: BulkFolderAffected[] = [] + const covered = new Set() + + const requested = new Set() + for (const folderId of folderIds) { + if (!rowsById.has(folderId)) { + notFound.push(folderId) + continue + } + requested.add(folderId) + } + + /** + * One `childrenByParent` index for the whole plan. Building it per requested + * folder would be O(rows) each time — up to `MAX_FOLDERS_PER_WORKSPACE` Map + * writes per selected folder, all but the first thrown away. + */ + const childrenByParent = indexFolderChildren(rows) + const descendantsOf = new Map() + for (const folderId of requested) { + descendantsOf.set(folderId, collectDescendantFolderIdsFrom(childrenByParent, folderId)) + } + + const insideAnotherSelection = new Set() + for (const [folderId, descendants] of descendantsOf) { + for (const descendantId of descendants) { + if (requested.has(descendantId)) insideAnotherSelection.add(descendantId) + } + } + + for (const folderId of folderIds) { + const row = rowsById.get(folderId) + if (!row || covered.has(folderId)) continue + const entry = { id: row.id, name: row.name } + if (insideAnotherSelection.has(folderId)) { + contained.push(entry) + continue + } + selected.push(entry) + covered.add(folderId) + for (const descendantId of descendantsOf.get(folderId) ?? []) covered.add(descendantId) + } + + /** + * A contained folder's subtree is covered by its ancestor, but record it + * anyway: the ancestor's descendant walk and this one are the same set, and + * an explicit add keeps `covered` correct even if the tree contains a cycle + * the walk had to cut short. + */ + for (const folder of contained) { + covered.add(folder.id) + for (const descendantId of descendantsOf.get(folder.id) ?? []) covered.add(descendantId) + } + + return { selected, notFound, contained, covered } +} + +/** + * The halves of a plan a caller's bulk outcome absorbs verbatim: ids nothing + * resolved to, and folders a selected ancestor already carries. + * + * Declared as push sinks rather than concrete arrays so each domain can pass + * its own outcome arrays, whose element unions (`'table' | 'folder'`, + * `'knowledgeBase' | 'folder'`) are wider than the folder entries written into + * them. + */ +export interface FolderPlanSink { + notFound: { push(entry: { kind: 'folder'; id: string }): unknown } + skipped: { push(entry: { kind: 'folder'; id: string; name: string }): unknown } +} + +/** Projects a plan's unactionable halves into a bulk outcome. */ +export function foldFolderPlan(plan: FolderSelectionPlan, outcome: FolderPlanSink): void { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) +} + +export interface BulkFolderOutcome { + succeeded: BulkFolderAffected[] + failed: BulkFolderFailure[] +} + +export interface BulkFolderDeleteOutcome extends BulkFolderOutcome { + /** Folders archived across every cascade, including the selected folders themselves. */ + folderCount: number + /** Resources of `resourceType` archived by the cascades. */ + resourceCount: number +} + +/** + * Re-parents each selected folder under `targetParentId`. + * + * Best-effort per folder, matching the resource half of the same request. + * `updateFolder` owns the invariants a caller must not be trusted with — a + * folder cannot become its own parent, cannot move under one of its own + * descendants, and cannot cross into another workspace — and reports them as + * `validation` failures, which surface here as a per-folder reason. + * + * Per-folder realtime notification is suppressed and one batch notification is + * sent instead: every per-item notify carries an identical body and triggers an + * identical workspace-wide invalidation, so a 100-folder move would otherwise + * cost 100 sequential internal round trips and make every connected client + * refetch the same list 100 times for one gesture. The batch notify runs in a + * `finally`, so a batch that ends early on an internal fault still tells the + * clients about the folders it did move. + */ +export async function bulkMoveFolders(params: { + workspaceId: string + resourceType: FolderResourceType + userId: string + folders: readonly BulkFolderAffected[] + targetParentId: string | null +}): Promise { + const succeeded: BulkFolderAffected[] = [] + const failed: BulkFolderFailure[] = [] + + try { + for (const folder of params.folders) { + const result = await updateFolder( + { + resourceType: params.resourceType, + folderId: folder.id, + workspaceId: params.workspaceId, + userId: params.userId, + parentId: params.targetParentId, + }, + { notify: false } + ) + if (result.success && result.folder) { + succeeded.push({ id: result.folder.id, name: result.folder.name }) + continue + } + if (result.errorCode === 'internal') throw new Error(result.error ?? 'Failed to move folder') + failed.push({ ...folder, reason: result.error ?? 'Failed to move folder' }) + } + } finally { + if (succeeded.length > 0) { + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + } + } + + logger.info('Bulk moved folders', { + workspaceId: params.workspaceId, + resourceType: params.resourceType, + succeeded: succeeded.length, + failed: failed.length, + }) + return { succeeded, failed } +} + +/** + * Archives each selected folder and everything under it. + * + * `projectAudit: false` — the calling application use case projects + * `FOLDER_DELETED` from the authoritative result with full principal + * attribution, which the orchestration's own `actorId: userId` entry cannot + * express for a non-human principal. + * + * `notify: false` for the same reason as {@link bulkMoveFolders}: one batch + * notification replaces a per-folder storm of identical invalidations, and it + * fires from a `finally` so a batch cut short by an internal fault still + * announces the folders it did archive. + */ +export async function bulkDeleteFolders(params: { + workspaceId: string + resourceType: FolderResourceType + userId: string + folders: readonly BulkFolderAffected[] + countKey: 'tables' | 'knowledgeBases' +}): Promise { + const succeeded: BulkFolderAffected[] = [] + const failed: BulkFolderFailure[] = [] + let folderCount = 0 + let resourceCount = 0 + + try { + for (const folder of params.folders) { + const result = await deleteFolder( + { + resourceType: params.resourceType, + folderId: folder.id, + workspaceId: params.workspaceId, + userId: params.userId, + folderName: folder.name, + }, + { projectAudit: false, notify: false } + ) + if (result.success) { + succeeded.push(folder) + folderCount += result.deletedItems?.folders ?? 0 + resourceCount += result.deletedItems?.[params.countKey] ?? 0 + continue + } + if (result.errorCode === 'internal') + throw new Error(result.error ?? 'Failed to delete folder') + failed.push({ ...folder, reason: result.error ?? 'Failed to delete folder' }) + } + } finally { + if (succeeded.length > 0) { + await notifyFolderResourceChanged(params.resourceType, params.workspaceId) + } + } + + logger.info('Bulk deleted folders', { + workspaceId: params.workspaceId, + resourceType: params.resourceType, + succeeded: succeeded.length, + failed: failed.length, + folderCount, + resourceCount, + }) + return { succeeded, failed, folderCount, resourceCount } +} diff --git a/apps/sim/lib/folders/orchestration.ts b/apps/sim/lib/folders/orchestration.ts index a91a8a75e3c..cb4f2c3cbe1 100644 --- a/apps/sim/lib/folders/orchestration.ts +++ b/apps/sim/lib/folders/orchestration.ts @@ -665,7 +665,17 @@ export async function createFolder(params: CreateFolderParams): Promise { +export async function updateFolder( + params: UpdateFolderParams, + /** + * `notify: false` for a caller that mutates several folders in one gesture + * and sends a single batch notification of its own. Every per-folder notify + * carries an identical body and triggers an identical workspace-wide + * invalidation, so a batch would otherwise fan out one internal round trip + * per item. Omitted, the orchestration notifies as before. + */ + options?: { notify?: boolean } +): Promise { const config = folderResourceConfig(params.resourceType) try { @@ -748,7 +758,9 @@ export async function updateFolder(params: UpdateFolderParams): Promise { +export async function deleteFolder( + params: DeleteFolderParams, + /** + * `projectAudit: false` for a caller that projects `FOLDER_DELETED` itself — + * an application use case attributes the entry to the acting `Principal`, + * which the `actorId: userId` entry below cannot express for a non-human + * principal. Omitted, the orchestration keeps recording its own entry, so + * every existing caller is unchanged. + * + * `notify: false` for a caller deleting several folders in one gesture that + * sends a single batch notification of its own — see {@link bulkDeleteFolders}. + */ + options?: { projectAudit?: boolean; notify?: boolean } +): Promise { const existing = await withFolderTreeLock(params.workspaceId, params.resourceType, async (tx) => { const [row] = await tx .select({ deletedAt: folderTable.deletedAt }) @@ -790,8 +815,8 @@ export async function deleteFolder(params: DeleteFolderParams): Promise { expect(collectDescendantFolderIds([], 'x')).toEqual([]) }) }) + +describe('collectDescendantFolderIdsFrom', () => { + /** + * The index-once path is what a bulk plan walks, so it must answer exactly + * what the rebuild-per-call path answers — including for the cycle case. + */ + it('matches the rebuild-per-call helper for every node in a tree', () => { + const index = indexFolderChildren(tree) + + for (const node of [...tree, { id: 'missing', parentId: null }]) { + expect(collectDescendantFolderIdsFrom(index, node.id).sort()).toEqual( + collectDescendantFolderIds(tree, node.id).sort() + ) + } + }) + + it('is reusable across folders without being rebuilt', () => { + const index = indexFolderChildren(tree) + + expect(collectDescendantFolderIdsFrom(index, 'a').sort()).toEqual(['a1', 'a1x', 'a2']) + expect(collectDescendantFolderIdsFrom(index, 'a').sort()).toEqual(['a1', 'a1x', 'a2']) + expect(collectDescendantFolderIdsFrom(index, 'b')).toEqual([]) + }) + + it('terminates on a parent cycle', () => { + const cyclic: FolderNode[] = [ + { id: 'x', parentId: 'y' }, + { id: 'y', parentId: 'x' }, + ] + + expect(collectDescendantFolderIdsFrom(indexFolderChildren(cyclic), 'x')).toEqual(['y']) + }) +}) + +describe('indexFolderChildren', () => { + it('keys children by parent and drops roots', () => { + const index = indexFolderChildren(tree) + + expect(index.get('root')).toEqual(['a', 'b']) + expect(index.get('a')).toEqual(['a1', 'a2']) + expect(index.has('other')).toBe(false) + }) +}) diff --git a/apps/sim/lib/folders/subtree.ts b/apps/sim/lib/folders/subtree.ts index 87cc3ec3285..42e727dd9cc 100644 --- a/apps/sim/lib/folders/subtree.ts +++ b/apps/sim/lib/folders/subtree.ts @@ -4,16 +4,18 @@ export interface FolderNode { parentId: string | null } +/** Child ids keyed by parent id — the shape a descendant walk reads. */ +export type FolderChildrenIndex = ReadonlyMap + /** - * Returns every descendant of `folderId` from a flat folder list, excluding `folderId` - * itself. The caller supplies the rows, so this stays a pure function usable against a - * query result, a transaction snapshot, or test fixtures. + * Indexes a flat folder list by parent id. * - * Indexes children by parent once up front rather than rescanning the list per level, and - * tracks `seen` so a cycle (which the DB permits between constraint checks) terminates the - * walk instead of recursing forever. + * Exported so a caller walking many folders of the same list builds the index + * once instead of once per folder: {@link collectDescendantFolderIds} rebuilds + * it on every call, which is O(rows) each time, and a workspace's tree is + * bounded only by `MAX_FOLDERS_PER_WORKSPACE`. */ -export function collectDescendantFolderIds(folders: FolderNode[], folderId: string): string[] { +export function indexFolderChildren(folders: readonly FolderNode[]): FolderChildrenIndex { const childrenByParent = new Map() for (const folder of folders) { @@ -23,6 +25,20 @@ export function collectDescendantFolderIds(folders: FolderNode[], folderId: stri else childrenByParent.set(folder.parentId, [folder.id]) } + return childrenByParent +} + +/** + * Returns every descendant of `folderId` from a prebuilt {@link FolderChildrenIndex}, + * excluding `folderId` itself. + * + * Tracks `seen` so a cycle (which the DB permits between constraint checks) terminates the + * walk instead of recursing forever. + */ +export function collectDescendantFolderIdsFrom( + childrenByParent: FolderChildrenIndex, + folderId: string +): string[] { const descendants: string[] = [] const seen = new Set([folderId]) @@ -38,3 +54,17 @@ export function collectDescendantFolderIds(folders: FolderNode[], folderId: stri return descendants } + +/** + * Returns every descendant of `folderId` from a flat folder list, excluding `folderId` + * itself. The caller supplies the rows, so this stays a pure function usable against a + * query result, a transaction snapshot, or test fixtures. + * + * Indexes children by parent once up front rather than rescanning the list per level. A + * caller resolving descendants for several folders of the SAME list should index once with + * {@link indexFolderChildren} and walk with {@link collectDescendantFolderIdsFrom} instead, + * so the index is not rebuilt and discarded per folder. + */ +export function collectDescendantFolderIds(folders: FolderNode[], folderId: string): string[] { + return collectDescendantFolderIdsFrom(indexFolderChildren(folders), folderId) +} diff --git a/apps/sim/lib/knowledge/api/route-policies.ts b/apps/sim/lib/knowledge/api/route-policies.ts index b9ae9031575..d07c1c86c27 100644 --- a/apps/sim/lib/knowledge/api/route-policies.ts +++ b/apps/sim/lib/knowledge/api/route-policies.ts @@ -77,6 +77,13 @@ export const internalKnowledgeErrorPolicies = { update: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to update knowledge base')), delete: concealKnowledgeBase(internalKnowledgeErrorPolicy('Failed to delete knowledge base')), restore: concealKnowledgeBase(internalKnowledgeErrorPolicy('Internal server error')), + /** + * Workspace-scoped bulk routes. Deliberately not concealed: the request names + * a workspace, not one knowledge base, and per-item authorization failures + * are already folded into the response's `notFound` list by the use case. + */ + bulkMove: internalKnowledgeErrorPolicy('Failed to move knowledge bases'), + bulkDelete: internalKnowledgeErrorPolicy('Failed to delete knowledge bases'), default: internalKnowledgeErrorPolicy('Internal server error'), documents: concealKnowledgeBase( internalKnowledgeErrorPolicy('Failed to process knowledge document request') diff --git a/apps/sim/lib/knowledge/application/batch-policy.ts b/apps/sim/lib/knowledge/application/batch-policy.ts index 3e995996c01..03fb857c2d2 100644 --- a/apps/sim/lib/knowledge/application/batch-policy.ts +++ b/apps/sim/lib/knowledge/application/batch-policy.ts @@ -1,12 +1,27 @@ +import { + type BatchExecutionResult, + type BatchTerminalFailure, + requireBoundedResourceSelection, + rethrowBatchTerminalFailure, +} from '@/lib/core/application/batch-policy' import { OrchestrationError } from '@/lib/core/orchestration/types' - -export const MAX_KNOWLEDGE_BATCH_ITEMS = 100 +import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' export const ADD_WORKSPACE_FILES_COST_POLICY = { maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, usageAdmission: 'once_before_processing', } as const +export const BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +export const BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY = { + maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + export const BULK_DELETE_KNOWLEDGE_BASES_COST_POLICY = { maxItems: MAX_KNOWLEDGE_BATCH_ITEMS, execution: 'sequential_best_effort', @@ -17,17 +32,16 @@ export const BULK_DELETE_KNOWLEDGE_DOCUMENTS_COST_POLICY = { execution: 'sequential_best_effort', } as const -export interface KnowledgeBatchTerminalFailure { - error: unknown -} +/** Domain names for the shared batch shapes, so call sites read in knowledge terms. */ +export type KnowledgeBatchTerminalFailure = BatchTerminalFailure +export type KnowledgeBatchExecutionResult = BatchExecutionResult -export interface KnowledgeBatchExecutionResult { - terminalFailure?: KnowledgeBatchTerminalFailure -} - -export function rethrowKnowledgeBatchTerminalFailure(result: KnowledgeBatchExecutionResult): void { - if (result.terminalFailure) throw result.terminalFailure.error -} +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export const rethrowKnowledgeBatchTerminalFailure: (result: KnowledgeBatchExecutionResult) => void = + rethrowBatchTerminalFailure export function requireBoundedKnowledgeBatch( items: readonly string[], @@ -45,3 +59,25 @@ export function requireBoundedKnowledgeBatch( } return [...new Set(items)] } + +export interface BoundedKnowledgeSelection { + knowledgeBaseIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed knowledge-base/folder selection before any + * protected row is loaded. The cap is on the combined count — see + * {@link requireBoundedResourceSelection}. + */ +export function requireBoundedKnowledgeSelection( + knowledgeBaseIds: readonly string[], + folderIds: readonly string[], + maxItems: number +): BoundedKnowledgeSelection { + const selection = requireBoundedResourceSelection(knowledgeBaseIds, folderIds, maxItems, { + singular: 'knowledge base', + plural: 'knowledge bases', + }) + return { knowledgeBaseIds: selection.resourceIds, folderIds: selection.folderIds } +} diff --git a/apps/sim/lib/knowledge/application/bulk.test.ts b/apps/sim/lib/knowledge/application/bulk.test.ts new file mode 100644 index 00000000000..c7a778de503 --- /dev/null +++ b/apps/sim/lib/knowledge/application/bulk.test.ts @@ -0,0 +1,306 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + bulkDeleteFolders: vi.fn(), + bulkMoveFolders: vi.fn(), + deleteRecord: vi.fn(), + findActiveFolder: vi.fn(), + knowledgeBaseDeleted: vi.fn(), + planFolderSelection: vi.fn(), + resolveKnowledgeBase: vi.fn(), + resolvePermission: vi.fn(), + resolveWorkspace: vi.fn(), + updateRecord: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + KNOWLEDGE_BASE_UPDATED: 'knowledge_base.updated', + KNOWLEDGE_BASE_DELETED: 'knowledge_base.deleted', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + }, + AuditResourceType: { KNOWLEDGE_BASE: 'knowledge_base', FOLDER: 'folder' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/telemetry', () => ({ + PlatformEvents: { knowledgeBaseDeleted: mocks.knowledgeBaseDeleted }, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/folders/bulk', () => ({ + planFolderSelection: mocks.planFolderSelection, + bulkMoveFolders: mocks.bulkMoveFolders, + bulkDeleteFolders: mocks.bulkDeleteFolders, + /** Pure projection — mirrored here rather than mocked, so outcomes stay realistic. */ + foldFolderPlan: ( + plan: { notFound: string[]; contained: { id: string; name: string }[] }, + outcome: { + notFound: { kind: string; id: string }[] + skipped: { kind: string; id: string; name: string }[] + } + ) => { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) + }, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, + resolveActiveKnowledgeBaseContext: mocks.resolveKnowledgeBase, +})) +vi.mock('@/lib/knowledge/service', () => ({ + updateKnowledgeBase: mocks.updateRecord, + deleteKnowledgeBase: mocks.deleteRecord, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { bulkDeleteKnowledgeItems, bulkMoveKnowledgeItems } from '@/lib/knowledge/application/bulk' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + +function knowledgeContext(id: string, folderId: string | null = null) { + return { + ...workspaceContext, + knowledgeBaseId: id, + knowledgeBase: { id, name: `Base ${id}`, workspaceId: 'workspace-1', folderId }, + } +} + +const emptyPlan = { selected: [], notFound: [], contained: [], covered: new Set() } + +describe('knowledge bulk application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspace.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mocks.resolveKnowledgeBase.mockImplementation( + async ({ knowledgeBaseId }: { knowledgeBaseId: string }) => knowledgeContext(knowledgeBaseId) + ) + mocks.updateRecord.mockImplementation(async (id: string) => ({ id, name: `Base ${id}` })) + mocks.deleteRecord.mockResolvedValue(undefined) + mocks.bulkMoveFolders.mockResolvedValue({ succeeded: [], failed: [] }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [], + failed: [], + folderCount: 0, + resourceCount: 0, + }) + }) + + it('rejects an empty selection before the canonical workspace load', async () => { + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', knowledgeBaseIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('bounds knowledge bases and folders against one combined cap', async () => { + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: Array.from({ length: 60 }, (_, index) => `knowledge-${index}`), + folderIds: Array.from({ length: 60 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspace).not.toHaveBeenCalled() + }) + + it('deletes knowledge bases and folders in one operation and audits every affected item', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Policies' }], + notFound: [], + contained: [], + covered: new Set(['folder-1']), + }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Policies' }], + failed: [], + folderCount: 3, + resourceCount: 4, + }) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.deleted).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + { kind: 'folder', id: 'folder-1', name: 'Policies' }, + ]) + expect(result.deletedItems).toEqual({ knowledgeBases: 5, folders: 3 }) + expect(mocks.audit).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'knowledge_base.deleted', resourceId: 'knowledge-1' }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'folder.deleted', resourceId: 'folder-1' }) + ) + expect(mocks.knowledgeBaseDeleted).toHaveBeenCalledExactlyOnceWith({ + knowledgeBaseId: 'knowledge-1', + }) + }) + + /** + * The whole point of taking both id lists in one request: a knowledge base + * that is also inside a selected folder must be deleted exactly once, by the + * folder's cascade. + */ + it('skips a knowledge base that a selected folder already carries', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Policies' }], + notFound: [], + contained: [], + covered: new Set(['folder-1', 'folder-child']), + }) + mocks.resolveKnowledgeBase.mockImplementation( + async ({ knowledgeBaseId }: { knowledgeBaseId: string }) => + knowledgeContext(knowledgeBaseId, 'folder-child') + ) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.skipped).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + ]) + expect(mocks.deleteRecord).not.toHaveBeenCalled() + }) + + it('conceals an inaccessible knowledge base as not-found rather than naming it', async () => { + mocks.resolveKnowledgeBase.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Knowledge base not found') + ) + + const result = await bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['workspace-2-knowledge'], + folderIds: [], + }, + }) + + expect(result.notFound).toEqual([{ kind: 'knowledgeBase', id: 'workspace-2-knowledge' }]) + expect(result.failed).toEqual([]) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination folder is not in the workspace', async () => { + mocks.findActiveFolder.mockResolvedValue(null) + + await expect( + bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: [], + targetFolderId: 'foreign-folder', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.updateRecord).not.toHaveBeenCalled() + }) + + it('moves knowledge bases and folders in one operation', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: ['ghost-folder'], + contained: [{ id: 'folder-3', name: 'Nested' }], + covered: new Set(['folder-2', 'folder-3']), + }) + mocks.bulkMoveFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-2', name: 'Archive' }], + failed: [], + }) + + const result = await bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-2', 'folder-3', 'ghost-folder'], + targetFolderId: 'folder-1', + }, + }) + + expect(result.moved).toEqual([ + { kind: 'knowledgeBase', id: 'knowledge-1', name: 'Base knowledge-1' }, + { kind: 'folder', id: 'folder-2', name: 'Archive' }, + ]) + expect(result.skipped).toEqual([{ kind: 'folder', id: 'folder-3', name: 'Nested' }]) + expect(result.notFound).toEqual([{ kind: 'folder', id: 'ghost-folder' }]) + expect(mocks.updateRecord).toHaveBeenCalledWith( + 'knowledge-1', + { folderId: 'folder-1' }, + 'request-1', + { assertedWorkspaceId: 'workspace-1' } + ) + }) + + it('records audit for the committed prefix before rethrowing an infrastructure failure', async () => { + mocks.deleteRecord.mockImplementation(async (knowledgeBaseId: string) => { + if (knowledgeBaseId === 'knowledge-2') throw new Error('connection reset') + }) + + await expect( + bulkDeleteKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1', 'knowledge-2', 'knowledge-3'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ action: 'knowledge_base.deleted', resourceId: 'knowledge-1' }) + ) + expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/knowledge/application/bulk.ts b/apps/sim/lib/knowledge/application/bulk.ts new file mode 100644 index 00000000000..7d21881ab57 --- /dev/null +++ b/apps/sim/lib/knowledge/application/bulk.ts @@ -0,0 +1,395 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { authorizeWorkspaceOperation } from '@/lib/core/application' +import { classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PlatformEvents } from '@/lib/core/telemetry' +import { generateRequestId } from '@/lib/core/utils/request' +import { + bulkDeleteFolders, + bulkMoveFolders, + foldFolderPlan, + planFolderSelection, +} from '@/lib/folders/bulk' +import { findActiveFolder } from '@/lib/folders/queries' +import { knowledgeDelegationPolicy } from '@/lib/knowledge/application/authorization' +import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case' +import { + type BoundedKnowledgeSelection, + BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY, + BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY, + type KnowledgeBatchExecutionResult, + requireBoundedKnowledgeSelection, + rethrowKnowledgeBatchTerminalFailure, +} from '@/lib/knowledge/application/batch-policy' +import { resolveKnowledgeAttributedUserId } from '@/lib/knowledge/application/billing' +import { + type ActiveKnowledgeBaseContext, + type KnowledgeWorkspaceContext, + resolveActiveKnowledgeBaseContext, + resolveKnowledgeWorkspaceContext, +} from '@/lib/knowledge/application/contexts' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { deleteKnowledgeBase, updateKnowledgeBase } from '@/lib/knowledge/service' + +const logger = createLogger('KnowledgeBulkApplication') + +const KNOWLEDGE_FOLDER_RESOURCE_TYPE = 'knowledge_base' as const + +export type BulkKnowledgeItemKind = 'knowledgeBase' | 'folder' + +export interface BulkKnowledgeItem { + kind: BulkKnowledgeItemKind + id: string + name: string +} + +export interface BulkKnowledgeFailure extends BulkKnowledgeItem { + reason: string +} + +/** An id the batch could not resolve. No name, because nothing was found to name. */ +export interface BulkKnowledgeMissing { + kind: BulkKnowledgeItemKind + id: string +} + +interface BulkKnowledgeContext extends KnowledgeWorkspaceContext, BoundedKnowledgeSelection {} + +export interface BulkMoveKnowledgeItemsInput { + assertedWorkspaceId: string + knowledgeBaseIds: string[] + folderIds: string[] + targetFolderId: string | null + source?: string +} + +export interface BulkDeleteKnowledgeItemsInput { + assertedWorkspaceId: string + knowledgeBaseIds: string[] + folderIds: string[] + source?: string +} + +interface BulkKnowledgeOutcome { + skipped: BulkKnowledgeItem[] + notFound: BulkKnowledgeMissing[] + failed: BulkKnowledgeFailure[] +} + +export interface BulkMoveKnowledgeItemsResult extends BulkKnowledgeOutcome { + moved: BulkKnowledgeItem[] +} + +export interface BulkDeleteKnowledgeItemsResult extends BulkKnowledgeOutcome { + deleted: BulkKnowledgeItem[] + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: { knowledgeBases: number; folders: number } +} + +interface BulkMoveKnowledgeItemsExecutionResult + extends BulkMoveKnowledgeItemsResult, + KnowledgeBatchExecutionResult {} +interface BulkDeleteKnowledgeItemsExecutionResult + extends BulkDeleteKnowledgeItemsResult, + KnowledgeBatchExecutionResult {} + +async function resolveBulkKnowledgeContext( + input: { assertedWorkspaceId: string; knowledgeBaseIds: string[]; folderIds: string[] }, + maxItems: number +): Promise { + const selection = requireBoundedKnowledgeSelection( + input.knowledgeBaseIds, + input.folderIds, + maxItems + ) + return { + ...(await resolveKnowledgeWorkspaceContext({ workspaceId: input.assertedWorkspaceId })), + ...selection, + } +} + +/** + * Walks the knowledge-base half of the selection. + * + * A base filed inside one of the selected folders is skipped: the folder + * operation already carries it, and acting on it separately would either pull + * it out of the folder it is travelling with or archive it under a second + * timestamp its folder's restore could never recover. + */ +async function runKnowledgeItems( + knowledgeBaseIds: readonly string[], + workspaceId: string, + covered: ReadonlySet, + authorize: (canonical: ActiveKnowledgeBaseContext) => Promise, + apply: (canonical: ActiveKnowledgeBaseContext) => Promise, + succeeded: BulkKnowledgeItem[], + outcome: BulkKnowledgeOutcome +): Promise { + for (const knowledgeBaseId of knowledgeBaseIds) { + let knowledgeBaseName = knowledgeBaseId + try { + const canonical = await resolveActiveKnowledgeBaseContext({ + knowledgeBaseId, + assertedWorkspaceId: workspaceId, + }) + knowledgeBaseName = canonical.knowledgeBase.name + const folderId = canonical.knowledgeBase.folderId + if (folderId && covered.has(folderId)) { + outcome.skipped.push({ + kind: 'knowledgeBase', + id: canonical.knowledgeBaseId, + name: knowledgeBaseName, + }) + continue + } + await authorize(canonical) + succeeded.push({ + kind: 'knowledgeBase', + id: canonical.knowledgeBaseId, + name: await apply(canonical), + }) + } catch (error) { + const disposition = classifyBulkItemError(error) + if (disposition.kind === 'notFound') { + outcome.notFound.push({ kind: 'knowledgeBase', id: knowledgeBaseId }) + continue + } + if (disposition.kind === 'failed') { + outcome.failed.push({ + kind: 'knowledgeBase', + id: knowledgeBaseId, + name: knowledgeBaseName, + reason: disposition.reason, + }) + continue + } + return disposition.error + } + } + return undefined +} + +export const bulkMoveKnowledgeItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkMoveItems, + resolveContext: ({ input }: { input: BulkMoveKnowledgeItemsInput }) => + resolveBulkKnowledgeContext(input, BULK_MOVE_KNOWLEDGE_ITEMS_COST_POLICY.maxItems), + async execute({ principal, input, context }): Promise { + /** + * The destination check and the folder plan read different rows and share + * no data, so they overlap rather than serialize. Both still complete + * before anything is written: an invalid target must fail the whole request + * rather than leave half the selection moved. + */ + const [targetFolder, plan] = await Promise.all([ + input.targetFolderId === null + ? null + : findActiveFolder( + input.targetFolderId, + context.workspaceId, + KNOWLEDGE_FOLDER_RESOURCE_TYPE + ), + planFolderSelection(context.workspaceId, KNOWLEDGE_FOLDER_RESOURCE_TYPE, context.folderIds), + ]) + if (input.targetFolderId !== null && !targetFolder) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } + + const moved: BulkKnowledgeItem[] = [] + const outcome: BulkKnowledgeOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + const terminalError = await runKnowledgeItems( + context.knowledgeBaseIds, + context.workspaceId, + plan.covered, + (canonical) => + authorizeWorkspaceOperation(principal, knowledgeOperations.bulkMoveItems, canonical, { + delegation: knowledgeDelegationPolicy, + }), + async (canonical) => + ( + await updateKnowledgeBase( + canonical.knowledgeBaseId, + { folderId: input.targetFolderId }, + generateRequestId(), + { assertedWorkspaceId: context.workspaceId } + ) + ).name, + moved, + outcome + ) + + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkMoveFolders({ + workspaceId: context.workspaceId, + resourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + userId: resolveKnowledgeAttributedUserId(principal, context), + folders: plan.selected, + targetParentId: input.targetFolderId, + }) + for (const folder of folders.succeeded) moved.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + } + + logger.info('Bulk moved knowledge bases and folders', { + workspaceId: context.workspaceId, + moved: moved.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + }) + return { + moved, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + }, + projectAudit: ({ input, result }) => + result.moved.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved knowledge base folder "${item.name}" to the workspace root` + : `Moved knowledge base folder "${item.name}" into another folder`, + metadata: { + source: input.source, + folderResourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + parentId: input.targetFolderId, + bulk: true, + }, + } + : { + action: AuditAction.KNOWLEDGE_BASE_UPDATED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved knowledge base "${item.name}" to the workspace root` + : `Moved knowledge base "${item.name}" into a folder`, + metadata: { + source: input.source, + updatedFields: ['folderId'], + folderId: input.targetFolderId, + bulk: true, + }, + } + ), + afterSuccess: ({ result }) => { + rethrowKnowledgeBatchTerminalFailure(result) + }, +}) + +export const bulkDeleteKnowledgeItems = defineAuthorizedKnowledgeUseCase({ + operation: knowledgeOperations.bulkDeleteItems, + resolveContext: ({ input }: { input: BulkDeleteKnowledgeItemsInput }) => + resolveBulkKnowledgeContext(input, BULK_DELETE_KNOWLEDGE_ITEMS_COST_POLICY.maxItems), + async execute({ principal, context }): Promise { + const plan = await planFolderSelection( + context.workspaceId, + KNOWLEDGE_FOLDER_RESOURCE_TYPE, + context.folderIds + ) + + const deleted: BulkKnowledgeItem[] = [] + const outcome: BulkKnowledgeOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + const terminalError = await runKnowledgeItems( + context.knowledgeBaseIds, + context.workspaceId, + plan.covered, + (canonical) => + authorizeWorkspaceOperation(principal, knowledgeOperations.bulkDeleteItems, canonical, { + delegation: knowledgeDelegationPolicy, + }), + async (canonical) => { + await deleteKnowledgeBase(canonical.knowledgeBaseId, generateRequestId(), { + assertedWorkspaceId: context.workspaceId, + }) + return canonical.knowledgeBase.name + }, + deleted, + outcome + ) + + const deletedItems = { knowledgeBases: deleted.length, folders: 0 } + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkDeleteFolders({ + workspaceId: context.workspaceId, + resourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + userId: resolveKnowledgeAttributedUserId(principal, context), + folders: plan.selected, + countKey: 'knowledgeBases', + }) + for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + deletedItems.folders = folders.folderCount + deletedItems.knowledgeBases += folders.resourceCount + } + + logger.info('Bulk deleted knowledge bases and folders', { + workspaceId: context.workspaceId, + deleted: deleted.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + deletedItems, + }) + return { + deleted, + deletedItems, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + }, + /** + * One entry per item the batch actually deleted. A folder's entry carries the + * cascade counts rather than one entry per cascaded knowledge base, matching + * what `DELETE /api/folders/[id]` already records for a single folder — a + * cascade is unbounded, and per-resource entries would let one request write + * thousands of audit rows. + */ + projectAudit: ({ input, result }) => + result.deleted.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: `Deleted knowledge base folder "${item.name}"`, + metadata: { + source: input.source, + folderResourceType: KNOWLEDGE_FOLDER_RESOURCE_TYPE, + affected: result.deletedItems, + bulk: true, + }, + } + : { + action: AuditAction.KNOWLEDGE_BASE_DELETED, + resourceType: AuditResourceType.KNOWLEDGE_BASE, + resourceId: item.id, + resourceName: item.name, + description: `Deleted knowledge base "${item.name}"`, + metadata: { source: input.source, knowledgeBaseName: item.name, bulk: true }, + } + ), + afterSuccess: ({ result }) => { + try { + for (const item of result.deleted) { + if (item.kind === 'knowledgeBase') { + PlatformEvents.knowledgeBaseDeleted({ knowledgeBaseId: item.id }) + } + } + } finally { + rethrowKnowledgeBatchTerminalFailure(result) + } + }, +}) diff --git a/apps/sim/lib/knowledge/application/knowledge-bases.ts b/apps/sim/lib/knowledge/application/knowledge-bases.ts index 88d80335a64..392e0ada0dd 100644 --- a/apps/sim/lib/knowledge/application/knowledge-bases.ts +++ b/apps/sim/lib/knowledge/application/knowledge-bases.ts @@ -11,7 +11,8 @@ import { PrincipalKindAuthorizationError, type WorkspaceOperation, } from '@/lib/core/application' -import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PlatformEvents } from '@/lib/core/telemetry' import { generateRequestId } from '@/lib/core/utils/request' import { loadActiveFolderPathIndex, resolveFolderPathFilter } from '@/lib/folders/queries' @@ -553,24 +554,20 @@ export const bulkDeleteKnowledgeBases = defineAuthorizedKnowledgeUseCase({ if (input.cancellationSignal?.aborted) break deleted.push(await executeDeleteKnowledgeBase({ context: canonical })) } catch (error) { - const classified = asOrchestrationError(error) - if ( - classified?.code === 'not_found' || - classified?.code === 'forbidden' || - classified?.code === 'unauthorized' - ) { + const disposition = classifyBulkItemError(error) + if (disposition.kind === 'notFound') { notFound.push(knowledgeBaseId) continue } - if (classified && classified.code !== 'internal') { + if (disposition.kind === 'failed') { failed.push({ id: knowledgeBaseId, name: knowledgeBaseName, - reason: classified.message, + reason: disposition.reason, }) continue } - terminalFailure = { error } + terminalFailure = { error: disposition.error } break } } diff --git a/apps/sim/lib/knowledge/application/operations.test.ts b/apps/sim/lib/knowledge/application/operations.test.ts index 64a1c433876..69b9fb11f8d 100644 --- a/apps/sim/lib/knowledge/application/operations.test.ts +++ b/apps/sim/lib/knowledge/application/operations.test.ts @@ -16,6 +16,8 @@ describe('knowledge operation registry', () => { 'knowledge.create', 'knowledge.update', 'knowledge.delete', + 'knowledge.bulk_move_items', + 'knowledge.bulk_delete_items', 'knowledge.bulk_delete', 'knowledge.vfs.rename', 'knowledge.vfs.delete', diff --git a/apps/sim/lib/knowledge/application/operations.ts b/apps/sim/lib/knowledge/application/operations.ts index 40c58d8b40d..e0931460074 100644 --- a/apps/sim/lib/knowledge/application/operations.ts +++ b/apps/sim/lib/knowledge/application/operations.ts @@ -65,6 +65,18 @@ export const knowledgeOperations = { workspaceApiKey: 'allow', ...ALL_PRINCIPAL_POLICY, }), + bulkMoveItems: defineWorkspaceOperation({ + id: 'knowledge.bulk_move_items', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), + bulkDeleteItems: defineWorkspaceOperation({ + id: 'knowledge.bulk_delete_items', + minimumRole: 'write', + workspaceApiKey: 'allow', + ...ALL_PRINCIPAL_POLICY, + }), bulkDelete: defineWorkspaceOperation({ id: 'knowledge.bulk_delete', minimumRole: 'write', diff --git a/apps/sim/lib/knowledge/constants.ts b/apps/sim/lib/knowledge/constants.ts index 45e4aa1cc60..7b75bc6ae17 100644 --- a/apps/sim/lib/knowledge/constants.ts +++ b/apps/sim/lib/knowledge/constants.ts @@ -6,6 +6,13 @@ export const KNOWLEDGE_BASE_DESCRIPTION_MAX_LENGTH = 10_000 export const MAX_KNOWLEDGE_BASES_PER_WORKSPACE = 10_000 /** Hard bound for path-indexed knowledge folder trees and recursive cascades. */ export const MAX_KNOWLEDGE_FOLDERS_PER_WORKSPACE = MAX_FOLDERS_PER_WORKSPACE + +/** + * Maximum items a knowledge bulk request may address by identifier. Lives here + * rather than in the application batch policy so the boundary contracts can + * bound their id arrays without pulling a server-only module into client code. + */ +export const MAX_KNOWLEDGE_BATCH_ITEMS = 100 /** Hard bound for connector-type rows projected onto one knowledge-base list. */ export const MAX_KNOWLEDGE_CONNECTOR_TYPE_ROWS_PER_LIST = 100_000 /** Maximum documents accepted by one internal bulk-create command. */ diff --git a/apps/sim/lib/table/api/route-policies.ts b/apps/sim/lib/table/api/route-policies.ts index ab0303bb014..679c0deb676 100644 --- a/apps/sim/lib/table/api/route-policies.ts +++ b/apps/sim/lib/table/api/route-policies.ts @@ -72,6 +72,14 @@ const internalTableGroupErrorPolicy = extendInternalErrorPolicy( * authorization failures behind the same not-found wording. */ export const internalTableErrorPolicies = { + /** + * Workspace-scoped bulk routes. They name a workspace, not one table, so + * there is no table whose existence a 403 could betray — per-item + * authorization failures are already folded into the response's `notFound` + * list by the use case. A lock that escapes the per-item classifier still + * renders as 423. + */ + bulk: internalTableGroupErrorPolicy, concealTableAuthorization: createInternalResourceConcealmentPolicy({ base: internalOrchestrationErrorPolicy, notFoundMessage: 'Table not found', diff --git a/apps/sim/lib/table/application/batch-policy.ts b/apps/sim/lib/table/application/batch-policy.ts new file mode 100644 index 00000000000..5c5e22c2d45 --- /dev/null +++ b/apps/sim/lib/table/application/batch-policy.ts @@ -0,0 +1,57 @@ +import { + type BatchExecutionResult, + type BatchTerminalFailure, + requireBoundedResourceSelection, + rethrowBatchTerminalFailure, +} from '@/lib/core/application/batch-policy' +import { MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' + +/** + * Bulk table operations run one authorized single-table mutation per item and + * report a per-item outcome, matching the knowledge domain's + * `sequential_best_effort` bulk policy. There is no single-statement archive or + * re-parent primitive that could make the batch atomic: archiving a table + * cascades, and each item is authorized against its own canonical row. + */ +export const BULK_MOVE_TABLES_COST_POLICY = { + maxItems: MAX_TABLE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +export const BULK_DELETE_TABLES_COST_POLICY = { + maxItems: MAX_TABLE_BATCH_ITEMS, + execution: 'sequential_best_effort', +} as const + +/** Domain names for the shared batch shapes, so call sites read in table terms. */ +export type TableBatchTerminalFailure = BatchTerminalFailure +export type TableBatchExecutionResult = BatchExecutionResult + +/** + * Re-throws the failure that ended a batch early. Called after audit has been + * projected, so the items that did commit are still recorded. + */ +export const rethrowTableBatchTerminalFailure: (result: TableBatchExecutionResult) => void = + rethrowBatchTerminalFailure + +export interface BoundedTableSelection { + tableIds: string[] + folderIds: string[] +} + +/** + * Deduplicates and bounds a mixed table/folder selection before any protected + * row is loaded. The cap is on the combined count — see + * {@link requireBoundedResourceSelection}. + */ +export function requireBoundedTableSelection( + tableIds: readonly string[], + folderIds: readonly string[], + maxItems: number +): BoundedTableSelection { + const selection = requireBoundedResourceSelection(tableIds, folderIds, maxItems, { + singular: 'table', + plural: 'tables', + }) + return { tableIds: selection.resourceIds, folderIds: selection.folderIds } +} diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts new file mode 100644 index 00000000000..7b354e34f30 --- /dev/null +++ b/apps/sim/lib/table/application/bulk.test.ts @@ -0,0 +1,379 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + audit: vi.fn(), + bulkDeleteFolders: vi.fn(), + bulkMoveFolders: vi.fn(), + deleteTable: vi.fn(), + findActiveFolder: vi.fn(), + moveTableToFolder: vi.fn(), + planFolderSelection: vi.fn(), + resolvePermission: vi.fn(), + resolveTableContext: vi.fn(), + resolveWorkspaceContext: vi.fn(), + signal: vi.fn(), + notifyTables: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { + TABLE_DELETED: 'table.deleted', + TABLE_UPDATED: 'table.updated', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + }, + AuditResourceType: { TABLE: 'table', FOLDER: 'folder' }, + recordAudit: mocks.audit, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) +vi.mock('@/lib/core/utils/request', () => ({ generateRequestId: () => 'request-1' })) +vi.mock('@/lib/folders/bulk', () => ({ + planFolderSelection: mocks.planFolderSelection, + bulkMoveFolders: mocks.bulkMoveFolders, + bulkDeleteFolders: mocks.bulkDeleteFolders, + /** Pure projection — mirrored here rather than mocked, so outcomes stay realistic. */ + foldFolderPlan: ( + plan: { notFound: string[]; contained: { id: string; name: string }[] }, + outcome: { + notFound: { kind: string; id: string }[] + skipped: { kind: string; id: string; name: string }[] + } + ) => { + for (const id of plan.notFound) outcome.notFound.push({ kind: 'folder', id }) + for (const folder of plan.contained) outcome.skipped.push({ kind: 'folder', ...folder }) + }, +})) +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceTablesChanged: mocks.notifyTables, +})) +vi.mock('@/lib/folders/queries', () => ({ findActiveFolder: mocks.findActiveFolder })) +vi.mock('@/lib/table', () => ({ + deleteTable: mocks.deleteTable, + moveTableToFolder: mocks.moveTableToFolder, +})) +vi.mock('@/lib/table/application/context', () => ({ + resolveActiveTableContext: mocks.resolveTableContext, + resolveTableWorkspaceContext: mocks.resolveWorkspaceContext, +})) +vi.mock('@/lib/table/events', () => ({ signalTableSchemaChanged: mocks.signal })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { bulkDeleteTables, bulkMoveTables } from '@/lib/table/application/bulk' +import { TableLockedError } from '@/lib/table/mutation-locks' + +const workspaceContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } as const + +function tableContext(id: string, folderId: string | null = null) { + return { + ...workspaceContext, + tableId: id, + table: { id, name: `Table ${id}`, workspaceId: 'workspace-1', folderId }, + } +} + +const emptyPlan = { selected: [], notFound: [], contained: [], covered: new Set() } + +describe('table bulk application use cases', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveWorkspaceContext.mockResolvedValue(workspaceContext) + mocks.resolvePermission.mockResolvedValue('write') + mocks.planFolderSelection.mockResolvedValue(emptyPlan) + mocks.findActiveFolder.mockResolvedValue({ id: 'folder-1' }) + mocks.resolveTableContext.mockImplementation(async ({ tableId }: { tableId: string }) => + tableContext(tableId) + ) + mocks.moveTableToFolder.mockResolvedValue({ name: 'Moved' }) + mocks.deleteTable.mockResolvedValue({ + archived: { name: 'Archived', workspaceId: 'workspace-1' }, + }) + mocks.bulkMoveFolders.mockResolvedValue({ succeeded: [], failed: [] }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [], + failed: [], + folderCount: 0, + resourceCount: 0, + }) + }) + + it('rejects an empty selection before the canonical workspace load', async () => { + await expect( + bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + expect(mocks.deleteTable).not.toHaveBeenCalled() + }) + + it('bounds tables and folders against one combined cap', async () => { + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: Array.from({ length: 60 }, (_, index) => `table-${index}`), + folderIds: Array.from({ length: 60 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + + expect(mocks.resolveWorkspaceContext).not.toHaveBeenCalled() + }) + + it('deletes tables and folders in one operation and audits every affected item', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Reports' }], + notFound: [], + contained: [], + covered: new Set(['folder-1']), + }) + mocks.bulkDeleteFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-1', name: 'Reports' }], + failed: [], + folderCount: 2, + resourceCount: 5, + }) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.deleted).toEqual([ + { kind: 'table', id: 'table-1', name: 'Archived' }, + { kind: 'folder', id: 'folder-1', name: 'Reports' }, + ]) + expect(result.deletedItems).toEqual({ tables: 6, folders: 2 }) + expect(mocks.audit).toHaveBeenCalledTimes(2) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'table.deleted', resourceId: 'table-1' }) + ) + expect(mocks.audit).toHaveBeenCalledWith( + expect.objectContaining({ action: 'folder.deleted', resourceId: 'folder-1' }) + ) + }) + + /** + * The whole point of taking both id lists in one request: a table that is + * also inside a selected folder must be archived exactly once, under the + * folder's cascade timestamp, or the folder's restore could never recover it. + */ + it('skips a table that a selected folder already carries', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-1', name: 'Reports' }], + notFound: [], + contained: [], + covered: new Set(['folder-1', 'folder-child']), + }) + mocks.resolveTableContext.mockImplementation(async ({ tableId }: { tableId: string }) => + tableContext(tableId, 'folder-child') + ) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-1'], + }, + }) + + expect(result.skipped).toEqual([{ kind: 'table', id: 'table-1', name: 'Table table-1' }]) + expect(mocks.deleteTable).not.toHaveBeenCalled() + expect(mocks.audit).not.toHaveBeenCalledWith( + expect.objectContaining({ action: 'table.deleted' }) + ) + }) + + it('reports a locked table as a per-item failure without stranding the rest', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-locked') throw new TableLockedError('delete') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + const result = await bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-locked', 'table-2'], + folderIds: [], + }, + }) + + expect(result.failed).toHaveLength(1) + expect(result.failed[0]).toMatchObject({ kind: 'table', id: 'table-locked' }) + expect(result.deleted).toEqual([{ kind: 'table', id: 'table-2', name: 'Archived' }]) + }) + + it('conceals an inaccessible table as not-found rather than naming it', async () => { + mocks.resolveTableContext.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Table not found') + ) + + const result = await bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: ['other-workspace'], folderIds: [] }, + }) + + expect(result.notFound).toEqual([{ kind: 'table', id: 'other-workspace' }]) + expect(result.failed).toEqual([]) + expect(mocks.audit).not.toHaveBeenCalled() + }) + + it('fails the whole move when the destination folder is not in the workspace', async () => { + mocks.findActiveFolder.mockResolvedValue(null) + + await expect( + bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: [], + targetFolderId: 'foreign-folder', + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mocks.moveTableToFolder).not.toHaveBeenCalled() + }) + + it('moves tables and folders in one operation', async () => { + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: ['ghost-folder'], + contained: [{ id: 'folder-3', name: 'Nested' }], + covered: new Set(['folder-2', 'folder-3']), + }) + mocks.bulkMoveFolders.mockResolvedValue({ + succeeded: [{ id: 'folder-2', name: 'Archive' }], + failed: [], + }) + + const result = await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-2', 'folder-3', 'ghost-folder'], + targetFolderId: 'folder-1', + }, + }) + + expect(result.moved).toEqual([ + { kind: 'table', id: 'table-1', name: 'Moved' }, + { kind: 'folder', id: 'folder-2', name: 'Archive' }, + ]) + expect(result.skipped).toEqual([{ kind: 'folder', id: 'folder-3', name: 'Nested' }]) + expect(result.notFound).toEqual([{ kind: 'folder', id: 'ghost-folder' }]) + expect(mocks.bulkMoveFolders).toHaveBeenCalledWith( + expect.objectContaining({ targetParentId: 'folder-1' }) + ) + expect(mocks.signal).toHaveBeenCalledExactlyOnceWith('table-1') + }) + + /** + * One gesture, one live-list broadcast. A per-item notify is an internal HTTP + * round trip with an identical body, so a 100-item batch would otherwise make + * every connected client refetch the same list 100 times. + */ + it('suppresses the per-table notify and sends exactly one for the batch', async () => { + await bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2', 'table-3'], + folderIds: [], + targetFolderId: 'folder-1', + }, + }) + + expect(mocks.moveTableToFolder).toHaveBeenCalledTimes(3) + for (const call of mocks.moveTableToFolder.mock.calls) { + expect(call[4]).toEqual({ notify: false }) + } + expect(mocks.notifyTables).toHaveBeenCalledExactlyOnceWith('workspace-1') + }) + + it('still notifies for the prefix a batch committed before it failed', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-2') throw new Error('connection reset') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.notifyTables).toHaveBeenCalledExactlyOnceWith('workspace-1') + }) + + it('sends no notify when the batch archived nothing', async () => { + mocks.resolveTableContext.mockRejectedValue( + new OrchestrationError('not_found', 'Table not found') + ) + + await bulkDeleteTables.execute({ + principal, + input: { assertedWorkspaceId: 'workspace-1', tableIds: ['ghost'], folderIds: [] }, + }) + + expect(mocks.notifyTables).not.toHaveBeenCalled() + }) + + it('records audit for the committed prefix before rethrowing an infrastructure failure', async () => { + mocks.deleteTable.mockImplementation(async (tableId: string) => { + if (tableId === 'table-2') throw new Error('connection reset') + return { archived: { name: 'Archived', workspaceId: 'workspace-1' } } + }) + + await expect( + bulkDeleteTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1', 'table-2', 'table-3'], + folderIds: [], + }, + }) + ).rejects.toThrow('connection reset') + + expect(mocks.audit).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ action: 'table.deleted', resourceId: 'table-1' }) + ) + expect(mocks.bulkDeleteFolders).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts new file mode 100644 index 00000000000..afbadf40ca7 --- /dev/null +++ b/apps/sim/lib/table/application/bulk.ts @@ -0,0 +1,422 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { type BulkItemDisposition, classifyBulkItemError } from '@/lib/core/application/bulk-items' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { generateRequestId } from '@/lib/core/utils/request' +import { + bulkDeleteFolders, + bulkMoveFolders, + foldFolderPlan, + planFolderSelection, +} from '@/lib/folders/bulk' +import { findActiveFolder } from '@/lib/folders/queries' +import { notifyWorkspaceTablesChanged } from '@/lib/realtime/notify' +import { deleteTable, moveTableToFolder } from '@/lib/table' +import { authorizeTableOperation } from '@/lib/table/application/authorization' +import { defineAuthorizedTableUseCase } from '@/lib/table/application/authorized-table-use-case' +import { + type BoundedTableSelection, + BULK_DELETE_TABLES_COST_POLICY, + BULK_MOVE_TABLES_COST_POLICY, + requireBoundedTableSelection, + rethrowTableBatchTerminalFailure, + type TableBatchExecutionResult, +} from '@/lib/table/application/batch-policy' +import { + type ActiveTableContext, + resolveActiveTableContext, + resolveTableWorkspaceContext, + type TableWorkspaceContext, +} from '@/lib/table/application/context' +import { tableOperations } from '@/lib/table/application/operations' +import { signalTableSchemaChanged } from '@/lib/table/events' +import { TableLockedError } from '@/lib/table/mutation-locks' + +const logger = createLogger('TableBulkApplication') + +const TABLE_FOLDER_RESOURCE_TYPE = 'table' as const + +export type BulkTableItemKind = 'table' | 'folder' + +export interface BulkTableItem { + kind: BulkTableItemKind + id: string + name: string +} + +export interface BulkTableFailure extends BulkTableItem { + reason: string +} + +/** An id the batch could not resolve. No name, because nothing was found to name. */ +export interface BulkTableMissing { + kind: BulkTableItemKind + id: string +} + +interface BulkTablesContext extends TableWorkspaceContext, BoundedTableSelection {} + +export interface BulkMoveTablesInput { + assertedWorkspaceId: string + tableIds: string[] + folderIds: string[] + targetFolderId: string | null +} + +export interface BulkDeleteTablesInput { + assertedWorkspaceId: string + tableIds: string[] + folderIds: string[] +} + +interface BulkTablesOutcome { + skipped: BulkTableItem[] + notFound: BulkTableMissing[] + failed: BulkTableFailure[] +} + +export interface BulkMoveTablesResult extends BulkTablesOutcome { + moved: BulkTableItem[] +} + +export interface BulkDeleteTablesResult extends BulkTablesOutcome { + deleted: BulkTableItem[] + /** Totals across the explicit deletes and every folder cascade they triggered. */ + deletedItems: { tables: number; folders: number } +} + +interface BulkMoveTablesExecutionResult extends BulkMoveTablesResult, TableBatchExecutionResult {} +interface BulkDeleteTablesExecutionResult + extends BulkDeleteTablesResult, + TableBatchExecutionResult {} + +async function resolveBulkTablesContext( + input: { assertedWorkspaceId: string; tableIds: string[]; folderIds: string[] }, + maxItems: number +): Promise { + const selection = requireBoundedTableSelection(input.tableIds, input.folderIds, maxItems) + return { + ...(await resolveTableWorkspaceContext(input.assertedWorkspaceId)), + ...selection, + } +} + +/** + * A lock is a per-table verdict, not an infrastructure fault: one locked table + * must not strand the rest of the selection. `TableLockedError` is an + * `HttpError`, so it never carries an orchestration code of its own and the + * shared classification cannot see it. + */ +function tableLockVerdict(error: unknown): BulkItemDisposition | undefined { + if (error instanceof TableLockedError) return { kind: 'failed', reason: error.message } + return undefined +} + +/** + * Resolves the destination folder once, before anything is written, so an + * invalid target fails the whole request rather than leaving half the selection + * moved. Scoped to `resourceType: 'table'` so a folder id from another + * resource's tree cannot file tables somewhere the Tables list never renders. + */ +async function requireTableFolder(workspaceId: string, folderId: string | null): Promise { + if (folderId === null) return + if (!(await findActiveFolder(folderId, workspaceId, TABLE_FOLDER_RESOURCE_TYPE))) { + throw new OrchestrationError('not_found', 'Folder not found in this workspace') + } +} + +/** + * Sends ONE live-list notification for the whole batch. + * + * Every per-table notify is an internal HTTP round trip with an identical body + * and broadcasts an identical workspace-wide invalidation, so a per-item + * fan-out would make every connected client refetch the same list once per + * item, and — with a 2s timeout each — could stall a 100-item request for + * minutes when the socket pod is unreachable. The per-item notifies are + * therefore suppressed at the mutation and replaced by this one call, made from + * a `finally` so a batch that ends early still announces what it did commit. + * + * Folder items are excluded: `bulkMoveFolders`/`bulkDeleteFolders` send their + * own single folder-resource notification, which fans out to the same room. + */ +async function notifyBatchedTableChanges( + workspaceId: string, + items: readonly BulkTableItem[] +): Promise { + if (items.some((item) => item.kind === 'table')) { + await notifyWorkspaceTablesChanged(workspaceId) + } +} + +/** + * Walks the table half of the selection. + * + * A table filed inside one of the selected folders is skipped: the folder + * operation already carries it, and acting on it separately would either pull + * it out of the folder it is travelling with or archive it under a second + * timestamp its folder's restore could never recover. + */ +async function runTableItems( + tableIds: readonly string[], + workspaceId: string, + covered: ReadonlySet, + authorize: (canonical: ActiveTableContext) => Promise, + /** Runs against an already-authorized canonical table. Returns its authoritative name. */ + apply: (canonical: ActiveTableContext) => Promise, + succeeded: BulkTableItem[], + outcome: BulkTablesOutcome +): Promise { + for (const tableId of tableIds) { + let tableName = tableId + try { + const canonical = await resolveActiveTableContext({ + tableId, + assertedWorkspaceId: workspaceId, + }) + tableName = canonical.table.name + if (canonical.table.folderId && covered.has(canonical.table.folderId)) { + outcome.skipped.push({ kind: 'table', id: canonical.table.id, name: tableName }) + continue + } + await authorize(canonical) + succeeded.push({ + kind: 'table', + id: canonical.table.id, + name: await apply(canonical), + }) + } catch (error) { + const disposition = classifyBulkItemError(error, tableLockVerdict) + if (disposition.kind === 'notFound') { + outcome.notFound.push({ kind: 'table', id: tableId }) + continue + } + if (disposition.kind === 'failed') { + outcome.failed.push({ + kind: 'table', + id: tableId, + name: tableName, + reason: disposition.reason, + }) + continue + } + return disposition.error + } + } + return undefined +} + +export const bulkMoveTables = defineAuthorizedTableUseCase({ + operation: tableOperations.bulkMove, + resolveContext: ({ input }: { input: BulkMoveTablesInput }) => + resolveBulkTablesContext(input, BULK_MOVE_TABLES_COST_POLICY.maxItems), + async execute({ principal, input, context }): Promise { + /** + * The destination check and the folder plan read different rows and share + * no data, so they overlap rather than serialize. Both still complete + * before anything is written: an invalid target must fail the whole request + * rather than leave half the selection moved. + */ + const [, plan] = await Promise.all([ + requireTableFolder(context.workspaceId, input.targetFolderId), + planFolderSelection(context.workspaceId, TABLE_FOLDER_RESOURCE_TYPE, context.folderIds), + ]) + + const moved: BulkTableItem[] = [] + const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + try { + const terminalError = await runTableItems( + context.tableIds, + context.workspaceId, + plan.covered, + (canonical) => authorizeTableOperation(principal, tableOperations.bulkMove, canonical), + async (canonical) => + ( + await moveTableToFolder( + canonical.table.id, + context.workspaceId, + input.targetFolderId, + generateRequestId(), + { notify: false } + ) + ).name, + moved, + outcome + ) + + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkMoveFolders({ + workspaceId: context.workspaceId, + resourceType: TABLE_FOLDER_RESOURCE_TYPE, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + folders: plan.selected, + targetParentId: input.targetFolderId, + }) + for (const folder of folders.succeeded) moved.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + } + + logger.info('Bulk moved tables and folders', { + workspaceId: context.workspaceId, + moved: moved.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + }) + return { + moved, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + } finally { + await notifyBatchedTableChanges(context.workspaceId, moved) + } + }, + projectAudit: ({ input, result }) => + result.moved.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved table folder "${item.name}" to the workspace root` + : `Moved table folder "${item.name}" into another folder`, + metadata: { + folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, + parentId: input.targetFolderId, + bulk: true, + }, + } + : { + action: AuditAction.TABLE_UPDATED, + resourceType: AuditResourceType.TABLE, + resourceId: item.id, + resourceName: item.name, + description: + input.targetFolderId === null + ? `Moved table "${item.name}" to the workspace root` + : `Moved table "${item.name}" into a folder`, + metadata: { op: 'move', folderId: input.targetFolderId, bulk: true }, + } + ), + afterSuccess: ({ result }) => { + try { + for (const item of result.moved) { + if (item.kind === 'table') signalTableSchemaChanged(item.id) + } + } finally { + rethrowTableBatchTerminalFailure(result) + } + }, +}) + +export const bulkDeleteTables = defineAuthorizedTableUseCase({ + operation: tableOperations.bulkDelete, + resolveContext: ({ input }: { input: BulkDeleteTablesInput }) => + resolveBulkTablesContext(input, BULK_DELETE_TABLES_COST_POLICY.maxItems), + async execute({ principal, context }): Promise { + const plan = await planFolderSelection( + context.workspaceId, + TABLE_FOLDER_RESOURCE_TYPE, + context.folderIds + ) + + const deleted: BulkTableItem[] = [] + const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] } + foldFolderPlan(plan, outcome) + + try { + const terminalError = await runTableItems( + context.tableIds, + context.workspaceId, + plan.covered, + (canonical) => authorizeTableOperation(principal, tableOperations.bulkDelete, canonical), + async (canonical) => { + const { archived } = await deleteTable(canonical.table.id, generateRequestId(), { + expectedWorkspaceId: context.workspaceId, + skipNotify: true, + }) + if (!archived) throw new OrchestrationError('not_found', 'Table not found') + return archived.name + }, + deleted, + outcome + ) + + const deletedItems = { tables: deleted.length, folders: 0 } + if (terminalError === undefined && plan.selected.length > 0) { + const folders = await bulkDeleteFolders({ + workspaceId: context.workspaceId, + resourceType: TABLE_FOLDER_RESOURCE_TYPE, + userId: resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }).attributedUserId, + folders: plan.selected, + countKey: 'tables', + }) + for (const folder of folders.succeeded) deleted.push({ kind: 'folder', ...folder }) + for (const folder of folders.failed) outcome.failed.push({ kind: 'folder', ...folder }) + deletedItems.folders = folders.folderCount + deletedItems.tables += folders.resourceCount + } + + logger.info('Bulk archived tables and folders', { + workspaceId: context.workspaceId, + deleted: deleted.length, + skipped: outcome.skipped.length, + notFound: outcome.notFound.length, + failed: outcome.failed.length, + deletedItems, + }) + return { + deleted, + deletedItems, + ...outcome, + ...(terminalError !== undefined && { terminalFailure: { error: terminalError } }), + } + } finally { + await notifyBatchedTableChanges(context.workspaceId, deleted) + } + }, + /** + * One entry per item the batch actually archived. A folder's entry carries + * the cascade counts rather than one entry per cascaded table, matching what + * `DELETE /api/folders/[id]` already records for a single folder — a cascade + * is unbounded, and per-resource entries would let one request write + * thousands of audit rows. + */ + projectAudit: ({ result }) => + result.deleted.map((item) => + item.kind === 'folder' + ? { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: item.id, + resourceName: item.name, + description: `Deleted table folder "${item.name}"`, + metadata: { + folderResourceType: TABLE_FOLDER_RESOURCE_TYPE, + affected: result.deletedItems, + bulk: true, + }, + } + : { + action: AuditAction.TABLE_DELETED, + resourceType: AuditResourceType.TABLE, + resourceId: item.id, + resourceName: item.name, + description: `Archived table "${item.name}"`, + metadata: { bulk: true }, + } + ), + afterSuccess: ({ result }) => { + rethrowTableBatchTerminalFailure(result) + }, +}) diff --git a/apps/sim/lib/table/application/operations.ts b/apps/sim/lib/table/application/operations.ts index dd590edd4f7..42476b3783a 100644 --- a/apps/sim/lib/table/application/operations.ts +++ b/apps/sim/lib/table/application/operations.ts @@ -80,6 +80,8 @@ export const tableOperations = { create: writeOperation('tables.create'), update: writeOperation('tables.update'), delete: writeOperation('tables.delete'), + bulkMove: writeOperation('tables.bulk_move'), + bulkDelete: writeOperation('tables.bulk_delete'), renameByVfsPath: defineWorkspaceOperation({ id: 'tables.vfs.rename', minimumRole: 'write', diff --git a/apps/sim/lib/table/constants.ts b/apps/sim/lib/table/constants.ts index 7ab2e7aa098..b53a1faecee 100644 --- a/apps/sim/lib/table/constants.ts +++ b/apps/sim/lib/table/constants.ts @@ -5,6 +5,13 @@ import { randomInt, randomItem } from '@sim/utils/random' import { env, envNumber } from '@/lib/core/config/env' +/** + * Maximum tables addressable by identifier in one bulk request. Matches the + * knowledge domain's `MAX_KNOWLEDGE_BATCH_ITEMS` so a multi-select on either + * list page is capped the same way. + */ +export const MAX_TABLE_BATCH_ITEMS = 100 + export const TABLE_LIMITS = { MAX_TABLES_PER_WORKSPACE: 100, MAX_ROWS_PER_TABLE: 10000, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 842a0a84d75..95d3ff94939 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -945,7 +945,15 @@ export async function moveTableToFolder( tableId: string, workspaceId: string, folderId: string | null, - requestId: string + requestId: string, + /** + * `notify: false` for a caller moving several tables in one gesture that + * sends a single batch notification of its own. Each notify is an internal + * HTTP round trip with an identical body and triggers an identical + * workspace-wide invalidation, so a per-item fan-out makes every connected + * client refetch the same list once per moved table. + */ + options?: { notify?: boolean } ): Promise<{ name: string }> { const updates: Partial = { folderId, @@ -981,7 +989,7 @@ export async function moveTableToFolder( logger.info(`[${requestId}] Moved table ${tableId} to folder ${folderId ?? 'root'}`) // Live tables list: a move changes each table's folder placement in the list result. - await notifyWorkspaceTablesChanged(workspaceId) + if (options?.notify ?? true) await notifyWorkspaceTablesChanged(workspaceId) return { name } } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 6665ac74a79..e3d040cdd7a 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1108, - zodRoutes: 1108, + totalRoutes: 1112, + zodRoutes: 1112, nonZodRoutes: 0, } as const diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 5e8ebeb9d3b..4b073ac9e54 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -138,16 +138,16 @@ } }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2091, + "modules": 2144, "gateways": { "apps/sim/triggers/registry.ts": 446, - "apps/sim/blocks/registry.ts": 317, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 249, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 198, - "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 168, + "apps/sim/blocks/registry.ts": 321, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 274, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 220, + "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 175, "apps/sim/lib/auth/index.ts": 158, - "apps/sim/lib/knowledge/orchestration/index.ts": 121, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 116 + "apps/sim/lib/knowledge/orchestration/index.ts": 141, + "apps/sim/lib/knowledge/orchestration/connectors.ts": 136 } }, "app/workspace/[workspaceId]/layout.tsx": { From f0b9e2368faaec455584fda6a32d2983322acddb Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 19:18:36 -0700 Subject: [PATCH 02/11] chore(audits): re-record route ratchet after merging staging --- scripts/check-api-validation-contracts.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index a4239c9c42d..1e8e05943c4 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1118, - zodRoutes: 1118, + totalRoutes: 1122, + zodRoutes: 1122, nonZodRoutes: 0, } as const From f4e6262c2d37d008740daba9e26bd9cce9df7794 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 19:28:54 -0700 Subject: [PATCH 03/11] fix(bulk): reject a move target inside the moving subtree and report contained folders deterministically --- .../components/folders/use-drag-teardown.ts | 2 +- apps/sim/lib/folders/bulk.test.ts | 95 +++++++++++++++++++ apps/sim/lib/folders/bulk.ts | 12 ++- .../lib/knowledge/application/bulk.test.ts | 29 ++++++ apps/sim/lib/knowledge/application/bulk.ts | 20 ++++ apps/sim/lib/table/application/bulk.test.ts | 29 ++++++ apps/sim/lib/table/application/bulk.ts | 20 ++++ 7 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/folders/bulk.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts index 7860a0b0ab7..6f5daf85b61 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts @@ -18,7 +18,7 @@ import { useEffect, useRef } from 'react' * abort drags that are still in progress — a bug this exact hook already shipped once. */ export function useDragTeardown(teardown: () => void): void { - const teardownRef = useRef(teardown) + const teardownRef = useRef<() => void>(teardown) teardownRef.current = teardown useEffect(() => { diff --git a/apps/sim/lib/folders/bulk.test.ts b/apps/sim/lib/folders/bulk.test.ts new file mode 100644 index 00000000000..08bb9092973 --- /dev/null +++ b/apps/sim/lib/folders/bulk.test.ts @@ -0,0 +1,95 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListActiveFolderRows } = vi.hoisted(() => ({ + mockListActiveFolderRows: vi.fn(), +})) + +vi.mock('@/lib/folders/queries', () => ({ + listActiveFolderRows: mockListActiveFolderRows, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + deleteFolder: vi.fn(), + updateFolder: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyFolderResourceChanged: vi.fn(), +})) + +import { planFolderSelection } from '@/lib/folders/bulk' + +/** + * `a` holds `a1`, which holds `a1x`. `b` is a sibling with nothing inside it, so a plan can + * distinguish "carried by an ancestor" from "selected in its own right". + */ +const TREE = [ + { id: 'a', name: 'A', parentId: null }, + { id: 'a1', name: 'A1', parentId: 'a' }, + { id: 'a1x', name: 'A1X', parentId: 'a1' }, + { id: 'b', name: 'B', parentId: null }, +] + +describe('planFolderSelection', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListActiveFolderRows.mockResolvedValue(TREE) + }) + + const plan = (folderIds: string[]) => planFolderSelection('ws-1', 'table', folderIds) + + it('selects a folder and reports nothing contained', async () => { + const result = await plan(['a']) + expect(result.selected).toEqual([{ id: 'a', name: 'A' }]) + expect(result.contained).toEqual([]) + expect([...result.covered].sort()).toEqual(['a', 'a1', 'a1x']) + }) + + it('reports an explicitly selected descendant as contained, not as a second selection', async () => { + const result = await plan(['a1', 'a']) + expect(result.selected).toEqual([{ id: 'a', name: 'A' }]) + expect(result.contained).toEqual([{ id: 'a1', name: 'A1' }]) + }) + + it('reports the same selection identically whichever order the ids arrive in', async () => { + // Regression: the ancestor marked its descendants covered, and testing `covered` before + // containment then dropped an explicitly requested descendant from every outcome + // category — so reversing the input silently changed what the API reported. + const ancestorFirst = await plan(['a', 'a1']) + const descendantFirst = await plan(['a1', 'a']) + + expect(ancestorFirst.selected).toEqual(descendantFirst.selected) + expect(ancestorFirst.contained).toEqual(descendantFirst.contained) + expect(ancestorFirst.contained).toEqual([{ id: 'a1', name: 'A1' }]) + }) + + it('never drops a requested folder from every outcome category', async () => { + for (const order of [ + ['a', 'a1', 'a1x'], + ['a1x', 'a1', 'a'], + ['a1', 'a1x', 'a'], + ]) { + const result = await plan(order) + const accounted = new Set([ + ...result.selected.map((f) => f.id), + ...result.contained.map((f) => f.id), + ...result.notFound, + ]) + expect([...accounted].sort()).toEqual(['a', 'a1', 'a1x']) + } + }) + + it('reports ids that resolve to nothing as notFound', async () => { + const result = await plan(['b', 'ghost']) + expect(result.selected).toEqual([{ id: 'b', name: 'B' }]) + expect(result.notFound).toEqual(['ghost']) + }) + + it('accounts for a duplicated id exactly once', async () => { + const result = await plan(['b', 'b']) + expect(result.selected).toEqual([{ id: 'b', name: 'B' }]) + }) +}) diff --git a/apps/sim/lib/folders/bulk.ts b/apps/sim/lib/folders/bulk.ts index cac642104fe..2e32c343132 100644 --- a/apps/sim/lib/folders/bulk.ts +++ b/apps/sim/lib/folders/bulk.ts @@ -92,14 +92,24 @@ export async function planFolderSelection( } } + const reported = new Set() for (const folderId of folderIds) { const row = rowsById.get(folderId) - if (!row || covered.has(folderId)) continue + if (!row || reported.has(folderId)) continue + reported.add(folderId) const entry = { id: row.id, name: row.name } + /** + * Containment is tested before `covered`, and that order matters: an explicitly requested + * folder that sits inside another selected folder must always be reported. Testing + * `covered` first made the outcome order-dependent — an ancestor processed earlier marked + * the descendant covered, so the descendant fell out of every outcome category and the + * same id set produced different results depending on the order it arrived in. + */ if (insideAnotherSelection.has(folderId)) { contained.push(entry) continue } + if (covered.has(folderId)) continue selected.push(entry) covered.add(folderId) for (const descendantId of descendantsOf.get(folderId) ?? []) covered.add(descendantId) diff --git a/apps/sim/lib/knowledge/application/bulk.test.ts b/apps/sim/lib/knowledge/application/bulk.test.ts index c7a778de503..077a9fd8f14 100644 --- a/apps/sim/lib/knowledge/application/bulk.test.ts +++ b/apps/sim/lib/knowledge/application/bulk.test.ts @@ -246,6 +246,35 @@ describe('knowledge bulk application use cases', () => { expect(mocks.updateRecord).not.toHaveBeenCalled() }) + it('fails the whole move when the destination sits inside the moving subtree', async () => { + // `covered` is the selected folders plus their descendants. Without an up-front check the + // knowledge bases move, the folders then fail their own cycle check, and the caller is left + // with a half-applied selection. + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: [], + contained: [], + covered: new Set(['folder-2', 'folder-2-child']), + }) + + for (const targetFolderId of ['folder-2', 'folder-2-child']) { + await expect( + bulkMoveKnowledgeItems.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + folderIds: ['folder-2'], + targetFolderId, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + + expect(mocks.updateRecord).not.toHaveBeenCalled() + expect(mocks.bulkMoveFolders).not.toHaveBeenCalled() + }) + it('moves knowledge bases and folders in one operation', async () => { mocks.planFolderSelection.mockResolvedValue({ selected: [{ id: 'folder-2', name: 'Archive' }], diff --git a/apps/sim/lib/knowledge/application/bulk.ts b/apps/sim/lib/knowledge/application/bulk.ts index 7d21881ab57..efc18b57d49 100644 --- a/apps/sim/lib/knowledge/application/bulk.ts +++ b/apps/sim/lib/knowledge/application/bulk.ts @@ -195,6 +195,26 @@ export const bulkMoveKnowledgeItems = defineAuthorizedKnowledgeUseCase({ throw new OrchestrationError('not_found', 'Folder not found in this workspace') } + /** + * The target must not be inside the subtree that is moving. `plan.covered` is exactly the + * selected folders plus their descendants, so this rejects both "into itself" and "into its + * own child" before anything is written. Without it the resources move, the folders then fail + * their cycle check, and the caller is left with a half-applied selection. + */ + if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into itself or one of its own subfolders' + ) + } + + /** + * The target must not be inside the subtree that is moving. `plan.covered` is exactly the + * selected folders plus their descendants, so this rejects both "into itself" and "into its + * own child" before anything is written. Without it the resources move, the folders then + * fail their cycle check, and the caller is left with a half-applied selection. + */ + const moved: BulkKnowledgeItem[] = [] const outcome: BulkKnowledgeOutcome = { skipped: [], notFound: [], failed: [] } foldFolderPlan(plan, outcome) diff --git a/apps/sim/lib/table/application/bulk.test.ts b/apps/sim/lib/table/application/bulk.test.ts index 7b354e34f30..221ef7be4b0 100644 --- a/apps/sim/lib/table/application/bulk.test.ts +++ b/apps/sim/lib/table/application/bulk.test.ts @@ -264,6 +264,35 @@ describe('table bulk application use cases', () => { expect(mocks.moveTableToFolder).not.toHaveBeenCalled() }) + it('fails the whole move when the destination sits inside the moving subtree', async () => { + // `covered` is the selected folders plus their descendants. Without an up-front check the + // tables move, the folders then fail their own cycle check, and the caller is left with a + // half-applied selection. + mocks.planFolderSelection.mockResolvedValue({ + selected: [{ id: 'folder-2', name: 'Archive' }], + notFound: [], + contained: [], + covered: new Set(['folder-2', 'folder-2-child']), + }) + + for (const targetFolderId of ['folder-2', 'folder-2-child']) { + await expect( + bulkMoveTables.execute({ + principal, + input: { + assertedWorkspaceId: 'workspace-1', + tableIds: ['table-1'], + folderIds: ['folder-2'], + targetFolderId, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + + expect(mocks.moveTableToFolder).not.toHaveBeenCalled() + expect(mocks.bulkMoveFolders).not.toHaveBeenCalled() + }) + it('moves tables and folders in one operation', async () => { mocks.planFolderSelection.mockResolvedValue({ selected: [{ id: 'folder-2', name: 'Archive' }], diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts index afbadf40ca7..8d7bc86bb6e 100644 --- a/apps/sim/lib/table/application/bulk.ts +++ b/apps/sim/lib/table/application/bulk.ts @@ -222,6 +222,26 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ planFolderSelection(context.workspaceId, TABLE_FOLDER_RESOURCE_TYPE, context.folderIds), ]) + /** + * The target must not be inside the subtree that is moving. `plan.covered` is exactly the + * selected folders plus their descendants, so this rejects both "into itself" and "into its + * own child" before anything is written. Without it the tables move, the folders then fail + * their cycle check, and the caller is left with a half-applied selection. + */ + if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into itself or one of its own subfolders' + ) + } + + /** + * The target must not be inside the subtree that is moving. `plan.covered` is exactly the + * selected folders plus their descendants, so this rejects both "into itself" and "into its + * own child" before anything is written. Without it the tables move, the folders then fail + * their cycle check, and the caller is left with a half-applied selection. + */ + const moved: BulkTableItem[] = [] const outcome: BulkTablesOutcome = { skipped: [], notFound: [], failed: [] } foldFolderPlan(plan, outcome) From 06ebaa58d3e3fad7a93203d83c868b1bb032651e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 19:38:07 -0700 Subject: [PATCH 04/11] improvement(resources): neutral drop affordance, longer spring delay, and a body drop target --- .../folders/use-folder-row-drag-drop.ts | 87 +++++++++++++++---- .../folders/use-spring-loaded-folder.ts | 9 +- .../components/resource/resource.tsx | 58 +++++++++++-- .../[workspaceId]/knowledge/knowledge.tsx | 1 + .../workspace/[workspaceId]/tables/tables.tsx | 1 + 5 files changed, 130 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index c6434958047..9ed67c3e712 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -43,11 +43,12 @@ export interface UseFolderRowDragDropOptions { /** Label shown in the drag ghost. */ getRowLabel: (rowId: string) => string /** - * Moves every row of the drag into `targetFolderId` in one call. Rows already sitting - * directly in the target are filtered out before this fires, and it is never called with - * both lists empty — so the consumer maps it straight onto its bulk-move operations. + * Moves every row of the drag into `targetFolderId` in one call (`null` is the workspace + * root). Rows already sitting directly in the target are filtered out before this fires, and + * it is never called with both lists empty — so the consumer maps it straight onto its + * bulk-move operations. */ - onMoveRows: (rows: FolderedRowMove, targetFolderId: string) => void + onMoveRows: (rows: FolderedRowMove, targetFolderId: string | null) => void /** * Checkbox selection, when the list has one. Dragging a selected row carries the whole * selection; dragging an unselected row collapses the selection onto it first, matching @@ -66,6 +67,12 @@ export interface UseFolderRowDragDropOptions { * back-stack entry. Omit to disable spring-loading. See {@link useSpringLoadedFolder}. */ onSpringOpenFolder?: (folderId: string, options: SpringOpenOptions) => void + /** + * The folder the list is currently showing (`null` at the workspace root). Enables dropping + * onto the list body to file into it — the only way to land a drag that spring-opened into an + * empty folder, which has no row to drop on. + */ + currentFolderId?: string | null } /** @@ -87,8 +94,10 @@ export function useFolderRowDragDrop({ onMoveRows, selection, onSpringOpenFolder, + currentFolderId = null, }: UseFolderRowDragDropOptions): RowDragDropConfig { const [activeDropTargetId, setActiveDropTargetId] = useState(null) + const [isBodyDropActive, setIsBodyDropActive] = useState(false) const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_ROW_IDS) /** * The in-flight drag source, mirrored outside React state because `onDragOver` fires far @@ -125,21 +134,23 @@ export function useFolderRowDragDrop({ springLoad.reset() setDraggedRowIds(EMPTY_ROW_IDS) setActiveDropTargetId(null) + setIsBodyDropActive(false) }, [dragGhost, springLoad]) useDragTeardown(endDrag) /** - * Splits the drag into the rows that would actually move, dropping any row already sitting - * directly in the target. Returns `null` when the drop is illegal outright — the target is - * not a folder, or it is one of the dragged folders or inside one, which would orphan a - * subtree into itself. + * Splits the drag into the rows that would actually move into `targetFolderId`, dropping any + * row already sitting directly there. `null` when the drop is illegal outright — the target is + * one of the dragged folders or inside one, which would orphan a subtree into itself — or when + * nothing would actually change. + * + * Takes a folder id rather than a row id because the destination is not always a row: the + * list body files into the folder currently open, which has no row of its own, and `null` + * addresses the workspace root. */ - const resolveMove = useCallback( - (targetRowId: string, sourceRowIds: string[]): FolderedRowMove | null => { - const target = parseFolderedRowId(targetRowId) - if (target.kind !== 'folder') return null - + const resolveMoveToFolder = useCallback( + (targetFolderId: string | null, sourceRowIds: string[]): FolderedRowMove | null => { const { descendantsByFolderId, getFolderParentId, getResourceFolderId } = optionsRef.current const folderIds: string[] = [] const resourceIds: string[] = [] @@ -147,13 +158,14 @@ export function useFolderRowDragDrop({ for (const sourceRowId of sourceRowIds) { const source = parseFolderedRowId(sourceRowId) if (source.kind === 'folder') { - if (source.id === target.id) return null - if (descendantsByFolderId.get(source.id)?.has(target.id)) return null - if ((getFolderParentId(source.id) ?? null) === target.id) continue + if (source.id === targetFolderId) return null + if (targetFolderId !== null && descendantsByFolderId.get(source.id)?.has(targetFolderId)) + return null + if ((getFolderParentId(source.id) ?? null) === targetFolderId) continue folderIds.push(source.id) continue } - if ((getResourceFolderId(source.id) ?? null) === target.id) continue + if ((getResourceFolderId(source.id) ?? null) === targetFolderId) continue resourceIds.push(source.id) } @@ -163,6 +175,16 @@ export function useFolderRowDragDrop({ [] ) + /** Row-targeted drop: only a folder row can receive one. */ + const resolveMove = useCallback( + (targetRowId: string, sourceRowIds: string[]): FolderedRowMove | null => { + const target = parseFolderedRowId(targetRowId) + if (target.kind !== 'folder') return null + return resolveMoveToFolder(target.id, sourceRowIds) + }, + [resolveMoveToFolder] + ) + return useMemo( () => ({ activeDropTargetId, @@ -260,6 +282,37 @@ export function useFolderRowDragDrop({ if (move) optionsRef.current.onMoveRows(move, target.id) }, onDragEnd: endDrag, + body: { + isActive: isBodyDropActive, + canDrop: canEdit && draggedRowIds.size > 0, + onDragOver: (e: DragEvent) => { + const sourceRowIds = draggedRowIdsRef.current + if (sourceRowIds.length === 0) return + /** + * Only light up when the drop would actually move something. A drag whose rows all + * already live here is a no-op, and showing a target for it would promise a change + * that never happens. + */ + if (!resolveMoveToFolder(currentFolderId, sourceRowIds)) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + setIsBodyDropActive(true) + }, + onDragLeave: (e: DragEvent) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setIsBodyDropActive(false) + }, + onDrop: (e: DragEvent) => { + e.preventDefault() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current + const move = + sourceRowIds.length > 0 ? resolveMoveToFolder(currentFolderId, sourceRowIds) : null + endDrag() + if (move) optionsRef.current.onMoveRows(move, currentFolderId) + }, + }, }), [ activeDropTargetId, diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts index 044322f2e11..0e460443919 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts @@ -5,11 +5,12 @@ import { useCallback, useEffect, useMemo, useRef } from 'react' /** * How long a drag must rest on a folder before it opens. * - * Matched to macOS Finder's spring-loaded folders, which the feature is modelled on. Shorter - * turns every pass over a folder into an accidental navigation; longer reads as unresponsive - * and the user gives up and drops at the wrong level. + * Deliberately slower than the workflow sidebar's 400ms hover-to-expand: that one opens a tree + * node in place and is trivially reversible, while this one navigates the whole list view out + * from under the drag. At 700ms a drag merely crossing a folder on its way elsewhere kept + * triggering it; the cost of waiting is far lower than the cost of an unwanted navigation. */ -export const SPRING_LOAD_DELAY_MS = 700 +export const SPRING_LOAD_DELAY_MS = 1000 /** How a spring-open writes the newly opened folder to the browser history. */ export interface SpringOpenOptions { diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 54d9af85f81..58ead27ffec 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -85,6 +85,22 @@ export interface SelectableConfig { disabled?: boolean } +/** + * Drop onto the list body, which files into the folder currently open. + * + * Rows alone are not enough: a drag that spring-opens into an empty folder has nothing to land + * on, so without this the gesture dead-ends and the item cannot be moved there at all. + */ +export interface BodyDropConfig { + /** The drag is over the body and releasing would move something. */ + isActive: boolean + /** A drag is in flight that this body could receive — drives the resting affordance. */ + canDrop: boolean + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void + onDrop: (e: DragEvent) => void +} + export interface RowDragDropConfig { activeDropTargetId?: string | null draggedRowIds?: Set @@ -96,6 +112,7 @@ export interface RowDragDropConfig { onDragLeave?: (e: DragEvent, rowId: string) => void onDrop?: (e: DragEvent, rowId: string) => void onDragEnd?: (e: DragEvent, rowId: string) => void + body?: BodyDropConfig } export interface PaginationConfig { @@ -293,6 +310,7 @@ const ResourceTable = memo(function ResourceTable({ }, [onLoadMore, hasMore]) const hasCheckbox = selectable != null + const bodyDrop = rowDragDrop?.body const handleSelectAll = useCallback( (checked: boolean | 'indeterminate') => { @@ -336,7 +354,17 @@ const ResourceTable = memo(function ResourceTable({ return (
-
+
+ {bodyDrop?.canDrop && rows.length === 0 && ( + /** + * An empty folder has no row to drop on, so the drag would otherwise dead-end here + * with no way to tell that releasing still files into this folder. Shown only while + * a droppable drag is in flight, so it never intrudes on the resting empty state. + */ +
+

Drop to move here

+

This folder is empty

+
+ )}
{ From 23f12dd5252d7e4565a735553da996de6961b309 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 19:44:49 -0700 Subject: [PATCH 05/11] fix(resources): drop into the open folder on Files, return on an unused spring-open, and guard bulk caps --- .../components/folders/use-drag-teardown.ts | 60 +++++++++--- .../folders/use-folder-row-drag-drop.ts | 44 ++++++++- .../components/action-bar/action-bar.tsx | 36 +++++++- .../resource/use-resource-row-selection.ts | 10 ++ .../workspace/[workspaceId]/files/files.tsx | 92 +++++++++++++++++++ .../[workspaceId]/knowledge/knowledge.tsx | 36 +++++++- .../workspace/[workspaceId]/tables/tables.tsx | 30 +++++- 7 files changed, 284 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts index 6f5daf85b61..d7e170ceae3 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts @@ -2,19 +2,35 @@ import { useEffect, useRef } from 'react' +/** + * How long `dragover` must go silent before the drag is presumed over. + * + * `dragover` fires continuously (roughly per animation frame) for as long as a drag is live and + * inside the window, so a gap this long means the drag ended somewhere no event of ours can + * observe. Generous on purpose: the cost of firing late is a few frames of stale drag styling, + * while firing early would tear down a drag the user is still holding. + */ +const DRAG_IDLE_TEARDOWN_MS = 400 + /** * Runs a drag's teardown wherever the drag actually ends. * - * `dragend` fires on the SOURCE ROW, which is not guaranteed to still exist: spring-loading - * navigates into another folder mid-drag, which unmounts it. A row-level handler would then - * never run, leaving the drag ghost stuck on the page, every row frozen at drag opacity, and a - * stale source id that makes the next drop resolve against rows the user never picked up. + * Three signals, because no single one is reliable here: + * + * 1. `drop` on `window` — a release over any valid target, wherever it bubbles from. + * 2. `dragend` on `window` — the normal end of a drag whose source row still exists. + * 3. A `dragover` idle watchdog — the case the first two miss. `dragend` is dispatched *at the + * source node*, so once spring-loading navigates the list and unmounts that row, the event + * has no path to `window` and neither listener above ever runs. Cancelling with Escape or + * releasing over nothing then leaves the ghost on the page, every row frozen at drag + * opacity, and the spring-open set uncleared so those folders refuse to open again. * - * Listening on `window` catches the event wherever it lands — including a drag cancelled with - * Escape or released outside the window, which never reaches a row at all. + * The watchdog also covers a drag that leaves the window entirely. If it re-enters, the visual + * state has been reset but the drop still resolves — the row ids live in `dataTransfer`, not in + * the state this tears down. * * `teardown` is read through a ref and the listeners bind once, deliberately. Depending on the - * callback would re-run this effect on every render, and any cleanup wired into it would then + * callback would re-run this effect on every render, and the teardown wired into it would then * abort drags that are still in progress — a bug this exact hook already shipped once. */ export function useDragTeardown(teardown: () => void): void { @@ -22,12 +38,32 @@ export function useDragTeardown(teardown: () => void): void { teardownRef.current = teardown useEffect(() => { - const handleDragEnd = () => teardownRef.current() - window.addEventListener('dragend', handleDragEnd) - window.addEventListener('drop', handleDragEnd) + let idleTimer: ReturnType | null = null + + const clearIdleTimer = () => { + if (idleTimer !== null) clearTimeout(idleTimer) + idleTimer = null + } + + const runTeardown = () => { + clearIdleTimer() + teardownRef.current() + } + + /** Restarted on every `dragover`; only elapses once the drag stops reporting. */ + const handleDragOver = () => { + clearIdleTimer() + idleTimer = setTimeout(runTeardown, DRAG_IDLE_TEARDOWN_MS) + } + + window.addEventListener('dragend', runTeardown) + window.addEventListener('drop', runTeardown) + window.addEventListener('dragover', handleDragOver) return () => { - window.removeEventListener('dragend', handleDragEnd) - window.removeEventListener('drop', handleDragEnd) + clearIdleTimer() + window.removeEventListener('dragend', runTeardown) + window.removeEventListener('drop', runTeardown) + window.removeEventListener('dragover', handleDragOver) } }, []) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index 9ed67c3e712..43e7c3225eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -66,7 +66,7 @@ export interface UseFolderRowDragDropOptions { * dropping first. Forward `options` to the folder-navigation setter so one drag leaves one * back-stack entry. Omit to disable spring-loading. See {@link useSpringLoadedFolder}. */ - onSpringOpenFolder?: (folderId: string, options: SpringOpenOptions) => void + onSpringOpenFolder?: (folderId: string | null, options: SpringOpenOptions) => void /** * The folder the list is currently showing (`null` at the workspace root). Enables dropping * onto the list body to file into it — the only way to land a drag that spring-opened into an @@ -123,12 +123,49 @@ export function useFolderRowDragDrop({ selection, } - const springLoad = useSpringLoadedFolder({ onSpringOpen: onSpringOpenFolder ?? noop }) + /** + * Where the drag started, and whether it ever navigated. A drag that spring-opens its way into + * a folder and is then cancelled — or dropped somewhere else entirely — would otherwise strand + * the user several levels deep in a folder they never chose to open, with the list showing + * somewhere they did not ask to be. The workflow sidebar collapses its own spring-opened + * folders for exactly this reason. + */ + const dragOriginFolderIdRef = useRef(null) + const didSpringOpenRef = useRef(false) + /** Set by whichever drop handler ran, so a completed drop keeps the destination on screen. */ + const dropHandledRef = useRef(false) + + const currentFolderIdRef = useRef(currentFolderId) + currentFolderIdRef.current = currentFolderId + + const springLoad = useSpringLoadedFolder({ + onSpringOpen: (folderId, options) => { + didSpringOpenRef.current = true + ;(onSpringOpenFolder ?? noop)(folderId, options) + }, + }) + + const onSpringOpenFolderRef = useRef(onSpringOpenFolder) + onSpringOpenFolderRef.current = onSpringOpenFolder const dragGhost = useRowDragGhost() /** Returns the list to its resting state once a drag is over, however it ended. */ const endDrag = useCallback(() => { + /** + * Navigate back to where the drag started when it spring-opened folders but never landed. + * `replace`, not `push`: the spring-opens are being undone, so they should leave no trace in + * the back stack rather than a trail the user has to walk out of. + */ + if ( + didSpringOpenRef.current && + !dropHandledRef.current && + dragOriginFolderIdRef.current !== currentFolderIdRef.current + ) { + onSpringOpenFolderRef.current?.(dragOriginFolderIdRef.current, { history: 'replace' }) + } + didSpringOpenRef.current = false + dropHandledRef.current = false dragGhost.remove() draggedRowIdsRef.current = [] springLoad.reset() @@ -198,6 +235,7 @@ export function useFolderRowDragDrop({ return } + dragOriginFolderIdRef.current = currentFolderIdRef.current const { selection } = optionsRef.current /** * Read the selection in display order rather than insertion order, so a shift-range @@ -271,6 +309,7 @@ export function useFolderRowDragDrop({ target.kind === 'folder' && sourceRowIds.length > 0 ? resolveMove(rowId, sourceRowIds) : null + if (move) dropHandledRef.current = true /** * Ends the drag here rather than leaving it to `dragend`. This handler stops @@ -309,6 +348,7 @@ export function useFolderRowDragDrop({ readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current const move = sourceRowIds.length > 0 ? resolveMoveToFolder(currentFolderId, sourceRowIds) : null + if (move) dropHandledRef.current = true endDrag() if (move) optionsRef.current.onMoveRows(move, currentFolderId) }, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx index 1a7d864179c..7935bc2a29e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -60,6 +60,12 @@ export interface ResourceActionBarProps { onDelete?: () => void /** Disables every action while a bulk mutation is in flight. */ isLoading?: boolean + /** + * Largest selection the bulk endpoints accept. Past it the server rejects the whole request, + * so the bar says so and disables the actions rather than letting the user confirm something + * that cannot succeed. + */ + maxSelectable?: number className?: string } @@ -83,10 +89,14 @@ export function ResourceActionBar({ moveOptions, onDelete, isLoading = false, + maxSelectable, className, }: ResourceActionBarProps) { if (selectedCount === 0) return null + const exceedsLimit = maxSelectable !== undefined && selectedCount > maxSelectable + const actionsDisabled = isLoading || exceedsLimit + return (
- - {selectedCount} selected + + {exceedsLimit + ? `${selectedCount} selected · select ${maxSelectable} or fewer` + : `${selectedCount} selected`}
{onDownload && ( @@ -105,7 +122,7 @@ export function ResourceActionBar({ icon={Download} label='Download' onClick={onDownload} - disabled={isLoading} + disabled={actionsDisabled} /> )} {onMove && moveOptions && ( @@ -113,7 +130,11 @@ export function ResourceActionBar({ - @@ -137,7 +158,12 @@ export function ResourceActionBar({ )} {onDelete && ( - + )}
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts index ad3c8674454..a4852fcb709 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/use-resource-row-selection.ts @@ -102,7 +102,17 @@ export function useResourceRowSelection({ const prevVisibleRowIdsRef = useRef(visibleRowIds) useEffect(() => { if (prevVisibleRowIdsRef.current === visibleRowIds) return + /** + * Identity is only a cheap first test — it changes for reasons that are not list changes. + * Both foldered pages rebuild every row on each inline-rename keystroke (the edit value + * lives in the row memo), so a rename would otherwise clear the shift anchor mid-edit and + * the next shift-click would start a fresh range instead of extending the user's. + */ + const unchanged = + prevVisibleRowIdsRef.current.length === visibleRowIds.length && + prevVisibleRowIdsRef.current.every((rowId, index) => rowId === visibleRowIds[index]) prevVisibleRowIdsRef.current = visibleRowIds + if (unchanged) return anchorIndexRef.current = NO_ANCHOR const visible = new Set(visibleRowIds) setSelectedRowIds((prev) => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index b3b87df37d7..d8dc451caf4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -368,6 +368,7 @@ export function Files() { const [isDirty, setIsDirty] = useState(false) const [saveStatus, setSaveStatus] = useState('idle') const [activeDropTargetId, setActiveDropTargetId] = useState(null) + const [isBodyDropActive, setIsBodyDropActive] = useState(false) const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_DRAGGED_ROW_IDS) const [previewMode, setPreviewMode] = useState(() => { if (isNewFile) return 'editor' @@ -717,6 +718,42 @@ export function Files() { const descendantFolderIdsByFolderId = useMemo(() => buildDescendantIndex(folders), [folders]) + /** + * Whether dropping `sourceRowIds` into `targetFolderId` would move anything. + * + * Takes a folder id rather than a row id because the destination is not always a row: the + * list body files into the folder currently open, which has no row of its own, and a drag + * that spring-opened into an empty folder has nothing else to land on. + */ + const isInvalidFolderTarget = useCallback( + (targetFolderId: string | null, sourceRowIds: string[]) => { + for (const sourceRowId of sourceRowIds) { + const source = parseRowId(sourceRowId) + if (source.kind !== 'folder') continue + if (source.id === targetFolderId) return true + if ( + targetFolderId !== null && + descendantFolderIdsByFolderId.get(source.id)?.has(targetFolderId) + ) + return true + } + + const allAlreadyInTarget = sourceRowIds.every((sourceRowId) => { + const source = parseRowId(sourceRowId) + if (source.kind === 'file') { + return ( + (filesRef.current.find((f) => f.id === source.id)?.folderId ?? null) === targetFolderId + ) + } + return ( + (foldersRef.current.find((f) => f.id === source.id)?.parentId ?? null) === targetFolderId + ) + }) + return allAlreadyInTarget + }, + [descendantFolderIdsByFolderId] + ) + const isInvalidDropTarget = useCallback( (targetRowId: string, sourceRowIds: string[]) => { const target = parseRowId(targetRowId) @@ -831,6 +868,7 @@ export function Files() { setDraggedRowIds(EMPTY_DRAGGED_ROW_IDS) setIsDraggingOver(false) setActiveDropTargetId(null) + setIsBodyDropActive(false) }, [dragGhost, springLoad]) useDragTeardown(endDrag) @@ -941,6 +979,56 @@ export function Files() { }) }, onDragEnd: endDrag, + body: { + isActive: isBodyDropActive, + canDrop: canEdit && draggedRowIds.size > 0, + onDragOver: (e: DragEvent) => { + const sourceRowIds = draggedRowIdsRef.current + const isExternalFileDrag = hasExternalFiles(e.dataTransfer) + if (!isExternalFileDrag) { + if (sourceRowIds.length === 0) return + if (isInvalidFolderTarget(currentFolderId, sourceRowIds)) return + } + e.preventDefault() + e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move' + setIsBodyDropActive(true) + }, + onDragLeave: (e: DragEvent) => { + const relatedTarget = e.relatedTarget + if (relatedTarget instanceof Node && e.currentTarget.contains(relatedTarget)) return + setIsBodyDropActive(false) + }, + onDrop: (e: DragEvent) => { + e.preventDefault() + const droppedFiles = Array.from(e.dataTransfer.files ?? []) + const sourceRowIds = + readRowDragPayload(e.dataTransfer, FILE_ROW_DRAG_MIME) ?? draggedRowIdsRef.current + const canMove = + droppedFiles.length === 0 && + sourceRowIds.length > 0 && + !isInvalidFolderTarget(currentFolderId, sourceRowIds) + + endDrag() + + if (droppedFiles.length > 0) { + void uploadFiles(droppedFiles, currentFolderId) + return + } + if (!canMove) return + + const fileIds: string[] = [] + const folderIds: string[] = [] + for (const sourceRowId of sourceRowIds) { + const source = parseRowId(sourceRowId) + if (source.kind === 'file') fileIds.push(source.id) + else folderIds.push(source.id) + } + void moveItems + .mutateAsync({ workspaceId, fileIds, folderIds, targetFolderId: currentFolderId }) + .then(() => clearSelection()) + .catch((error) => logger.error('Failed to move items into the open folder:', error)) + }, + }, }), [ activeDropTargetId, @@ -950,6 +1038,10 @@ export function Files() { selectedRowIds, visibleRowIds, isInvalidDropTarget, + isInvalidFolderTarget, + isBodyDropActive, + currentFolderId, + clearSelection, uploadFiles, workspaceId, ] diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx index 09274a962b4..c210b91ad45 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx @@ -8,6 +8,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' +import { MAX_KNOWLEDGE_BATCH_ITEMS } from '@/lib/knowledge/constants' import type { KnowledgeBaseData } from '@/lib/knowledge/types' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { @@ -630,6 +631,19 @@ export function Knowledge() { listRename.cancelRename, ]) + /** + * A dialog owns the keyboard while it is open. Without this, Escape closes the dialog AND + * clears the selection behind it, so a bulk-delete confirm submits against a selection the + * user just emptied; Delete and Cmd/Ctrl+A leak through the same way. + */ + const isAnyDialogOpen = () => + isCreateModalOpen || + isEditModalOpen || + isDeleteModalOpen || + isBulkDeleteModalOpen || + isTagsModalOpen || + folderPendingDelete !== null + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) const { @@ -639,7 +653,8 @@ export function Knowledge() { clearSelection, } = useResourceRowSelection({ visibleRowIds, - isKeyboardBlocked: () => !canEdit || listRenameRef.current.editingId !== null, + isKeyboardBlocked: () => + !canEdit || listRenameRef.current.editingId !== null || isAnyDialogOpen(), onDeleteSelected: () => handleBulkDelete(), }) @@ -941,6 +956,10 @@ export function Knowledge() { const moveRowsTo = useCallback( (rows: { knowledgeBaseIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { if (rows.knowledgeBaseIds.length === 0 && rows.folderIds.length === 0) return + if (rows.knowledgeBaseIds.length + rows.folderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS) { + toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to move at once`) + return + } bulkMoveKnowledgeBases.mutate( { ...rows, targetFolderId }, { @@ -965,10 +984,22 @@ export function Knowledge() { [moveRowsTo, selectedKnowledgeBaseIds, selectedFolderIds] ) + /** + * Enforced here rather than only on the action bar: the row context menu and the Delete key + * reach the same operation, and the server rejects an over-cap request outright — so without + * this the user confirms a delete that cannot succeed. + */ + const exceedsBatchCap = + selectedKnowledgeBaseIds.length + selectedFolderIds.length > MAX_KNOWLEDGE_BATCH_ITEMS + const handleBulkDelete = useCallback(() => { if (selectedKnowledgeBaseIds.length === 0 && selectedFolderIds.length === 0) return + if (exceedsBatchCap) { + toast.error(`Select ${MAX_KNOWLEDGE_BATCH_ITEMS} or fewer items to delete at once`) + return + } setIsBulkDeleteModalOpen(true) - }, [selectedKnowledgeBaseIds, selectedFolderIds]) + }, [selectedKnowledgeBaseIds, selectedFolderIds, exceedsBatchCap]) const confirmBulkDelete = useCallback(async () => { try { @@ -1248,6 +1279,7 @@ export function Knowledge() { moveOptions={canEdit ? bulkMoveOptions : undefined} onDelete={canEdit ? handleBulkDelete : undefined} isLoading={bulkMoveKnowledgeBases.isPending || bulkDeleteKnowledgeBases.isPending} + maxSelectable={MAX_KNOWLEDGE_BATCH_ITEMS} /> ), [ diff --git a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx index e4d51dc8057..a00a44cce24 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/tables.tsx @@ -9,7 +9,7 @@ import { getErrorMessage } from '@sim/utils/errors' import { useParams, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import type { TableDefinition } from '@/lib/table' -import { generateUniqueTableName } from '@/lib/table/constants' +import { generateUniqueTableName, MAX_TABLE_BATCH_ITEMS } from '@/lib/table/constants' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import type { DropdownOption, @@ -473,6 +473,14 @@ export function Tables() { [listRename.startRename] ) + /** + * A dialog owns the keyboard while it is open. Without this, Escape closes the dialog AND + * clears the selection behind it, so a bulk-delete confirm submits against a selection the + * user just emptied; Delete and Cmd/Ctrl+A leak through the same way. + */ + const isAnyDialogOpen = () => + isDeleteDialogOpen || isDeleteFolderDialogOpen || isBulkDeleteDialogOpen || isImportDialogOpen + const visibleRowIds = useMemo(() => rows.map((row) => row.id), [rows]) const { @@ -482,7 +490,7 @@ export function Tables() { clearSelection, } = useResourceRowSelection({ visibleRowIds, - isKeyboardBlocked: () => !canEdit || listRename.editingId !== null, + isKeyboardBlocked: () => !canEdit || listRename.editingId !== null || isAnyDialogOpen(), onDeleteSelected: () => handleBulkDelete(), }) @@ -858,6 +866,10 @@ export function Tables() { const moveRowsTo = useCallback( (rows: { tableIds: string[]; folderIds: string[] }, targetFolderId: string | null) => { if (rows.tableIds.length === 0 && rows.folderIds.length === 0) return + if (rows.tableIds.length + rows.folderIds.length > MAX_TABLE_BATCH_ITEMS) { + toast.error(`Select ${MAX_TABLE_BATCH_ITEMS} or fewer items to move at once`) + return + } bulkMoveTables.mutate( { ...rows, targetFolderId }, { @@ -882,10 +894,21 @@ export function Tables() { [moveRowsTo, selectedTableIds, selectedFolderIds] ) + /** + * Enforced here rather than only on the action bar: the row context menu and the Delete key + * reach the same operation, and the server rejects an over-cap request outright — so without + * this the user confirms a delete that cannot succeed. + */ + const exceedsBatchCap = selectedTableIds.length + selectedFolderIds.length > MAX_TABLE_BATCH_ITEMS + const handleBulkDelete = useCallback(() => { if (selectedTableIds.length === 0 && selectedFolderIds.length === 0) return + if (exceedsBatchCap) { + toast.error(`Select ${MAX_TABLE_BATCH_ITEMS} or fewer items to delete at once`) + return + } setIsBulkDeleteDialogOpen(true) - }, [selectedTableIds, selectedFolderIds]) + }, [selectedTableIds, selectedFolderIds, exceedsBatchCap]) const confirmBulkDelete = useCallback(async () => { try { @@ -1193,6 +1216,7 @@ export function Tables() { moveOptions={canEdit ? bulkMoveOptions : undefined} onDelete={canEdit ? handleBulkDelete : undefined} isLoading={bulkMoveTables.isPending || bulkDeleteTables.isPending} + maxSelectable={MAX_TABLE_BATCH_ITEMS} /> ), [ From eed4690bdd26a693b5c2420454dafc599da3aeb0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 20:01:08 -0700 Subject: [PATCH 06/11] fix(drag): end a drag on pointer resume instead of an idle timer --- .../folders/use-drag-teardown.test.tsx | 118 ++++++++++++++++++ .../components/folders/use-drag-teardown.ts | 60 ++++----- .../folders/use-folder-row-drag-drop.ts | 22 ++-- .../components/resource/resource.tsx | 20 --- .../workspace/[workspaceId]/files/files.tsx | 37 +++--- 5 files changed, 179 insertions(+), 78 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx new file mode 100644 index 00000000000..d107519d7d9 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.test.tsx @@ -0,0 +1,118 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' + +const mountedRoots: Root[] = [] + +function renderDragTeardown(teardown: () => void) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const root = createRoot(document.createElement('div')) + mountedRoots.push(root) + + function Probe() { + useDragTeardown(teardown) + return null + } + + act(() => { + root.render() + }) +} + +function fire(type: string) { + act(() => { + window.dispatchEvent(new Event(type, { bubbles: true })) + }) +} + +beforeEach(() => { + vi.useFakeTimers() +}) + +afterEach(() => { + act(() => { + for (const root of mountedRoots.splice(0)) root.unmount() + }) + vi.useRealTimers() +}) + +describe('useDragTeardown', () => { + it('tears down on dragend', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('dragend') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('tears down on drop', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('drop') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('tears down on the first pointermove after a drag, which dragend can miss', () => { + // Spring-loading unmounts the source row, so `dragend` — dispatched at that node — never + // reaches window. Browsers suppress pointer events during a drag, so the first one after + // is an exact signal that the drag ended. + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('pointermove') + + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('never tears down from the passage of time alone', () => { + // Regression: an idle-timeout version of this tore the drag down whenever the user rested + // on a folder waiting for it to spring open — the drag model reports only about every + // 350ms while the pointer is still, so any timeout in that range trips on a held drag. + // Nothing here may depend on a timer, so advancing the clock must change nothing. + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + act(() => { + vi.advanceTimersByTime(30_000) + }) + + expect(teardown).not.toHaveBeenCalled() + + // And the drag is still live, so a real end signal still lands. + fire('dragend') + expect(teardown).toHaveBeenCalledTimes(1) + }) + + it('ignores pointer movement when no drag is in flight', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('pointermove') + fire('pointermove') + + expect(teardown).not.toHaveBeenCalled() + }) + + it('tears down once per drag, not on every event after it ends', () => { + const teardown = vi.fn() + renderDragTeardown(teardown) + + fire('dragover') + fire('dragend') + fire('pointermove') + fire('pointermove') + + expect(teardown).toHaveBeenCalledTimes(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts index d7e170ceae3..a28ca2057a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts @@ -2,16 +2,6 @@ import { useEffect, useRef } from 'react' -/** - * How long `dragover` must go silent before the drag is presumed over. - * - * `dragover` fires continuously (roughly per animation frame) for as long as a drag is live and - * inside the window, so a gap this long means the drag ended somewhere no event of ours can - * observe. Generous on purpose: the cost of firing late is a few frames of stale drag styling, - * while firing early would tear down a drag the user is still holding. - */ -const DRAG_IDLE_TEARDOWN_MS = 400 - /** * Runs a drag's teardown wherever the drag actually ends. * @@ -19,15 +9,17 @@ const DRAG_IDLE_TEARDOWN_MS = 400 * * 1. `drop` on `window` — a release over any valid target, wherever it bubbles from. * 2. `dragend` on `window` — the normal end of a drag whose source row still exists. - * 3. A `dragover` idle watchdog — the case the first two miss. `dragend` is dispatched *at the + * 3. `pointermove` on `window` — the case the first two miss. `dragend` is dispatched *at the * source node*, so once spring-loading navigates the list and unmounts that row, the event * has no path to `window` and neither listener above ever runs. Cancelling with Escape or * releasing over nothing then leaves the ghost on the page, every row frozen at drag * opacity, and the spring-open set uncleared so those folders refuse to open again. * - * The watchdog also covers a drag that leaves the window entirely. If it re-enters, the visual - * state has been reset but the drop still resolves — the row ids live in `dataTransfer`, not in - * the state this tears down. + * The third signal works because browsers suppress mouse and pointer events for the duration of + * a native drag: the first `pointermove` after one starts can only mean it is over. That makes + * it exact, where a timer is not — the drag model fires `dragover` on roughly a 350ms cadence + * while the pointer is stationary, so an idle-timeout version of this tore down mid-drag + * whenever the user rested on a folder waiting for it to spring open. * * `teardown` is read through a ref and the listeners bind once, deliberately. Depending on the * callback would re-run this effect on every render, and the teardown wired into it would then @@ -38,32 +30,32 @@ export function useDragTeardown(teardown: () => void): void { teardownRef.current = teardown useEffect(() => { - let idleTimer: ReturnType | null = null - - const clearIdleTimer = () => { - if (idleTimer !== null) clearTimeout(idleTimer) - idleTimer = null + /** + * Set from `dragover` rather than `dragstart` so the flag only turns on once a drag is + * genuinely under way, and so a stray `pointermove` before the drag engages cannot tear + * down a drag that never started. + */ + let isDragging = false + + const markDragging = () => { + isDragging = true } - const runTeardown = () => { - clearIdleTimer() + const endDrag = () => { + if (!isDragging) return + isDragging = false teardownRef.current() } - /** Restarted on every `dragover`; only elapses once the drag stops reporting. */ - const handleDragOver = () => { - clearIdleTimer() - idleTimer = setTimeout(runTeardown, DRAG_IDLE_TEARDOWN_MS) - } - - window.addEventListener('dragend', runTeardown) - window.addEventListener('drop', runTeardown) - window.addEventListener('dragover', handleDragOver) + window.addEventListener('dragover', markDragging) + window.addEventListener('dragend', endDrag) + window.addEventListener('drop', endDrag) + window.addEventListener('pointermove', endDrag) return () => { - clearIdleTimer() - window.removeEventListener('dragend', runTeardown) - window.removeEventListener('drop', runTeardown) - window.removeEventListener('dragover', handleDragOver) + window.removeEventListener('dragover', markDragging) + window.removeEventListener('dragend', endDrag) + window.removeEventListener('drop', endDrag) + window.removeEventListener('pointermove', endDrag) } }, []) } diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index 43e7c3225eb..cd6c1efcaa5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -283,6 +283,12 @@ export function useFolderRowDragDrop({ */ if (sourceRowIds.length > 0) { setActiveDropTargetId(rowId) + /** + * The row is inside the scroll container, so moving onto it fires `dragleave` there + * with a contained `relatedTarget` — which that handler deliberately ignores. Without + * clearing here the row and the body would both render as the target at once. + */ + setIsBodyDropActive(false) /** * Armed on the same condition as the highlight, so a folder only springs open where a * drop was already possible. A folder the drag cannot legally enter never opens. @@ -323,19 +329,20 @@ export function useFolderRowDragDrop({ onDragEnd: endDrag, body: { isActive: isBodyDropActive, - canDrop: canEdit && draggedRowIds.size > 0, onDragOver: (e: DragEvent) => { const sourceRowIds = draggedRowIdsRef.current - if (sourceRowIds.length === 0) return /** - * Only light up when the drop would actually move something. A drag whose rows all - * already live here is a no-op, and showing a target for it would promise a change - * that never happens. + * Recomputed on every event rather than latched, because a spring-open changes the + * destination mid-drag: the folder just entered may not accept this drag, and an + * early return would leave the outline showing from the previous folder. Setting the + * same value repeatedly is free — React bails on an unchanged state write. */ - if (!resolveMoveToFolder(currentFolderId, sourceRowIds)) return + const canDrop = + sourceRowIds.length > 0 && resolveMoveToFolder(currentFolderId, sourceRowIds) !== null + setIsBodyDropActive(canDrop) + if (!canDrop) return e.preventDefault() e.dataTransfer.dropEffect = 'move' - setIsBodyDropActive(true) }, onDragLeave: (e: DragEvent) => { const relatedTarget = e.relatedTarget @@ -346,6 +353,7 @@ export function useFolderRowDragDrop({ e.preventDefault() const sourceRowIds = readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current + e.stopPropagation() const move = sourceRowIds.length > 0 ? resolveMoveToFolder(currentFolderId, sourceRowIds) : null if (move) dropHandledRef.current = true diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 58ead27ffec..9f023246a7a 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -94,8 +94,6 @@ export interface SelectableConfig { export interface BodyDropConfig { /** The drag is over the body and releasing would move something. */ isActive: boolean - /** A drag is in flight that this body could receive — drives the resting affordance. */ - canDrop: boolean onDragOver: (e: DragEvent) => void onDragLeave: (e: DragEvent) => void onDrop: (e: DragEvent) => void @@ -396,24 +394,6 @@ const ResourceTable = memo(function ResourceTable({ ))}
- {bodyDrop?.canDrop && rows.length === 0 && ( - /** - * An empty folder has no row to drop on, so the drag would otherwise dead-end here - * with no way to tell that releasing still files into this folder. Shown only while - * a droppable drag is in flight, so it never intrudes on the resting empty state. - */ -
-

Drop to move here

-

This folder is empty

-
- )}
0, onDragOver: (e: DragEvent) => { + /** + * Internal row drags only. An OS file drag is already owned by the page-level + * handler, which paints the full "Drop to upload" overlay and uploads into this same + * folder — claiming it here would double the affordance and, without stopping + * propagation, upload every dropped file twice. + */ + if (hasExternalFiles(e.dataTransfer)) return const sourceRowIds = draggedRowIdsRef.current - const isExternalFileDrag = hasExternalFiles(e.dataTransfer) - if (!isExternalFileDrag) { - if (sourceRowIds.length === 0) return - if (isInvalidFolderTarget(currentFolderId, sourceRowIds)) return - } + // Recomputed every event: a spring-open changes the destination mid-drag. + const canDrop = + sourceRowIds.length > 0 && !isInvalidFolderTarget(currentFolderId, sourceRowIds) + setIsBodyDropActive(canDrop) + if (!canDrop) return e.preventDefault() - e.dataTransfer.dropEffect = isExternalFileDrag ? 'copy' : 'move' - setIsBodyDropActive(true) + e.dataTransfer.dropEffect = 'move' }, onDragLeave: (e: DragEvent) => { const relatedTarget = e.relatedTarget @@ -999,21 +1007,16 @@ export function Files() { setIsBodyDropActive(false) }, onDrop: (e: DragEvent) => { + // Left to the page-level handler, which uploads into this folder already. + if (hasExternalFiles(e.dataTransfer)) return e.preventDefault() - const droppedFiles = Array.from(e.dataTransfer.files ?? []) + e.stopPropagation() const sourceRowIds = readRowDragPayload(e.dataTransfer, FILE_ROW_DRAG_MIME) ?? draggedRowIdsRef.current const canMove = - droppedFiles.length === 0 && - sourceRowIds.length > 0 && - !isInvalidFolderTarget(currentFolderId, sourceRowIds) + sourceRowIds.length > 0 && !isInvalidFolderTarget(currentFolderId, sourceRowIds) endDrag() - - if (droppedFiles.length > 0) { - void uploadFiles(droppedFiles, currentFolderId) - return - } if (!canMove) return const fileIds: string[] = [] From 94127c60e4559cf040ad4f54125e04abf78b9b20 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 14 Aug 2026 20:18:18 -0700 Subject: [PATCH 07/11] feat(resources): drag onto breadcrumbs to move back up, and round the drop ring --- .../components/folders/folder-breadcrumbs.ts | 3 +- .../components/folders/folders.test.ts | 113 ++++++++++++++++++ .../components/folders/move-options.tsx | 5 + .../folders/use-folder-row-drag-drop.ts | 45 +++++++ .../folders/use-spring-loaded-folder.test.tsx | 25 ++++ .../folders/use-spring-loaded-folder.ts | 21 ++-- .../components/resource-header/index.ts | 1 + .../resource-header/resource-header.tsx | 74 +++++++++++- .../components/resource/resource.tsx | 22 +++- .../workspace/[workspaceId]/files/files.tsx | 53 ++++++++ .../[workspaceId]/knowledge/knowledge.tsx | 1 + .../workspace/[workspaceId]/tables/tables.tsx | 1 + 12 files changed, 346 insertions(+), 18 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 7a2b3d88f50..1ff57a3becb 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -91,7 +91,7 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const trailing = options.trailing ?? NO_TRAILING_CRUMBS const items: BreadcrumbItem[] = [ - { label: rootLabel, icon: rootIcon, onClick: () => onNavigate(null) }, + { label: rootLabel, icon: rootIcon, folderId: null, onClick: () => onNavigate(null) }, ] breadcrumbs.forEach((folder, index) => { @@ -99,6 +99,7 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1 items.push({ label: folder.name, + folderId: folder.id, onClick: isOpenFolder ? undefined : () => onNavigate(folder.id), dropdownItems: isOpenFolder && options.currentFolderActions?.length 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 994dfaf5e49..68e6b3b218f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folders.test.ts @@ -11,10 +11,12 @@ import { nextUntitledFolderName } from '@/app/workspace/[workspaceId]/components import { folderRowId, parseFolderedRowId, + splitFolderedRowIds, } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' import { buildDescendantIndex, buildMoveOptions, + buildMoveOptionsExcludingSubtrees, parseMoveOptionValue, ROOT_MOVE_OPTION_VALUE, } from '@/app/workspace/[workspaceId]/components/folders/move-options' @@ -331,3 +333,114 @@ describe('folderAncestorChain', () => { expect(folderAncestorChain('a', (id) => folders[id]).map((f) => f.id)).toEqual(['b', 'a']) }) }) + +describe('splitFolderedRowIds', () => { + it('separates folder rows from resource rows', () => { + const { folderIds, resourceIds } = splitFolderedRowIds([ + folderRowId('f-1'), + 'res-1', + folderRowId('f-2'), + 'res-2', + ]) + + expect(folderIds).toEqual(['f-1', 'f-2']) + expect(resourceIds).toEqual(['res-1', 'res-2']) + }) + + it('returns empty lists for an empty selection', () => { + expect(splitFolderedRowIds([])).toEqual({ folderIds: [], resourceIds: [] }) + }) + + it('accepts a Set, which is how a selection is actually held', () => { + const { folderIds, resourceIds } = splitFolderedRowIds(new Set([folderRowId('f-1'), 'res-1'])) + expect(folderIds).toEqual(['f-1']) + expect(resourceIds).toEqual(['res-1']) + }) +}) + +describe('buildMoveOptionsExcludingSubtrees', () => { + /** `a` holds `a1`, which holds `a1x`; `b` is an unrelated sibling. */ + const folders = [makeFolder('a'), makeFolder('a1', 'a'), makeFolder('a1x', 'a1'), makeFolder('b')] + const descendantsByFolderId = buildDescendantIndex(folders) + const valuesOf = (nodes: ReturnType): string[] => + nodes.flatMap((node) => [node.value, ...valuesOf(node.children)]) + + it('offers every folder when nothing is excluded', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: [], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a', 'a1', 'a1x', 'b']) + }) + + it('excludes a moving folder and its whole subtree, never offering a cycle', () => { + // The invariant this helper exists to hold: a folder can never be filed into itself or + // anything beneath it, at any depth. + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'b']) + }) + + it('excludes the union of several selected subtrees', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a1', 'b'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE, 'a']) + }) + + it('always keeps the workspace root as a destination', () => { + const options = buildMoveOptionsExcludingSubtrees({ + folders, + rootLabel: 'Root', + excludeFolderIds: ['a', 'b'], + descendantsByFolderId, + }) + expect(valuesOf(options)).toEqual([ROOT_MOVE_OPTION_VALUE]) + }) +}) + +describe('folderBreadcrumbItems drag destinations', () => { + const chain = [makeFolder('a'), makeFolder('a1', 'a')] + + it('names the folder each crumb points at, so the header can accept a drop on it', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: chain, + onNavigate: vi.fn(), + }) + + expect(items.map((item) => item.folderId)).toEqual([null, 'a', 'a1']) + }) + + it('leaves a trailing crumb without a folder id, so it stays inert', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: chain, + onNavigate: vi.fn(), + trailing: [{ label: 'report.md', terminal: true }], + }) + + expect(items.at(-1)).toMatchObject({ label: 'report.md' }) + expect(items.at(-1)?.folderId).toBeUndefined() + }) + + it('gives the root crumb null rather than omitting it — the root is a real destination', () => { + const items = folderBreadcrumbItems({ + rootLabel: 'Files', + breadcrumbs: [], + onNavigate: vi.fn(), + }) + + expect(items).toHaveLength(1) + expect(items[0]).toHaveProperty('folderId', null) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx index 18a24b0ee79..865c50bca74 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/move-options.tsx @@ -184,6 +184,11 @@ export function renderMoveOptions( * Shared because that exclusion is a correctness invariant, not a preference: hand-copying it * per surface is how one list eventually offers a cyclic destination. Covers the single-folder * case too — pass a one-element array. + * + * Expanding each selection to its descendants is deliberately belt-and-braces: {@link + * buildMoveOptions} descends from the root, so an excluded folder already takes its subtree out + * of the walk. The explicit expansion keeps the invariant true of the exclusion set itself, so + * it survives that walk ever being replaced by a flat render. */ export function buildMoveOptionsExcludingSubtrees({ folders, diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index cd6c1efcaa5..7acd328b3a1 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -98,6 +98,7 @@ export function useFolderRowDragDrop({ }: UseFolderRowDragDropOptions): RowDragDropConfig { const [activeDropTargetId, setActiveDropTargetId] = useState(null) const [isBodyDropActive, setIsBodyDropActive] = useState(false) + const [activeBreadcrumbIndex, setActiveBreadcrumbIndex] = useState(null) const [draggedRowIds, setDraggedRowIds] = useState>(() => EMPTY_ROW_IDS) /** * The in-flight drag source, mirrored outside React state because `onDragOver` fires far @@ -172,6 +173,7 @@ export function useFolderRowDragDrop({ setDraggedRowIds(EMPTY_ROW_IDS) setActiveDropTargetId(null) setIsBodyDropActive(false) + setActiveBreadcrumbIndex(null) }, [dragGhost, springLoad]) useDragTeardown(endDrag) @@ -289,6 +291,7 @@ export function useFolderRowDragDrop({ * clearing here the row and the body would both render as the target at once. */ setIsBodyDropActive(false) + setActiveBreadcrumbIndex(null) /** * Armed on the same condition as the highlight, so a folder only springs open where a * drop was already possible. A folder the drag cannot legally enter never opens. @@ -327,6 +330,48 @@ export function useFolderRowDragDrop({ if (move) optionsRef.current.onMoveRows(move, target.id) }, onDragEnd: endDrag, + /** + * The breadcrumb is how a drag walks back UP. Spring-loading only ever goes deeper, so + * without this a drag that entered a folder can only leave it by being abandoned. + * Hovering a crumb navigates to it on the same timer a folder row uses, and releasing on + * one files the drag there directly. + */ + breadcrumb: { + activeIndex: activeBreadcrumbIndex, + onDragOver: (e: DragEvent, folderId: string | null, index: number) => { + const sourceRowIds = draggedRowIdsRef.current + const canDrop = + sourceRowIds.length > 0 && resolveMoveToFolder(folderId, sourceRowIds) !== null + /** + * Armed even when the drop itself would be a no-op — walking back through a crumb the + * rows already live in is exactly how a user returns to where they started, and + * refusing to navigate there would strand them. + */ + if (sourceRowIds.length > 0 && folderId !== currentFolderIdRef.current) { + springLoad.arm(folderId) + } + setActiveBreadcrumbIndex(canDrop ? index : null) + setIsBodyDropActive(false) + if (!canDrop) return + e.preventDefault() + e.stopPropagation() + e.dataTransfer.dropEffect = 'move' + }, + onDragLeave: (_e: DragEvent, index: number) => { + springLoad.disarm() + setActiveBreadcrumbIndex((current) => (current === index ? null : current)) + }, + onDrop: (e: DragEvent, folderId: string | null) => { + e.preventDefault() + e.stopPropagation() + const sourceRowIds = + readRowDragPayload(e.dataTransfer, DRAG_ROW_MIME) ?? draggedRowIdsRef.current + const move = sourceRowIds.length > 0 ? resolveMoveToFolder(folderId, sourceRowIds) : null + if (move) dropHandledRef.current = true + endDrag() + if (move) optionsRef.current.onMoveRows(move, folderId) + }, + }, body: { isActive: isBodyDropActive, onDragOver: (e: DragEvent) => { diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx index b51aaaee64a..fd20ab72c35 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.test.tsx @@ -185,6 +185,31 @@ describe('useSpringLoadedFolder', () => { expect(onSpringOpen).toHaveBeenCalledTimes(2) }) + it('springs to the workspace root, which a breadcrumb targets as null', () => { + // Walking a drag back UP goes through the breadcrumb, whose first crumb is the root — so + // null has to be a real destination here, distinct from "nothing armed". + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm(null)) + rest() + + expect(onSpringOpen).toHaveBeenCalledExactlyOnceWith(null, { history: 'push' }) + }) + + it('opens the root at most once per drag, like any other folder', () => { + const onSpringOpen = vi.fn() + const harness = renderSpringLoad(onSpringOpen) + + act(() => harness.get().arm(null)) + rest() + act(() => harness.get().arm('folder-a')) + act(() => harness.get().arm(null)) + rest() + + expect(onSpringOpen).toHaveBeenCalledTimes(1) + }) + it('never opens a folder after unmount', () => { const onSpringOpen = vi.fn() const harness = renderSpringLoad(onSpringOpen) diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts index 0e460443919..c4d07122d7f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder.ts @@ -27,7 +27,7 @@ export interface UseSpringLoadedFolderOptions { * over while deciding where to drop; replacing every level would overwrite the entry they * were actually standing on, so Back would leave the page instead of returning to it. */ - onSpringOpen: (folderId: string, options: SpringOpenOptions) => void + onSpringOpen: (folderId: string | null, options: SpringOpenOptions) => void delayMs?: number } @@ -37,7 +37,7 @@ export interface SpringLoadedFolder { * fires continuously: re-arming the folder already being timed does not restart it, so the * countdown reflects how long the drag has actually rested there. */ - arm: (folderId: string) => void + arm: (folderId: string | null) => void /** Cancels the pending open — the drag left the row, or the row stopped being a valid target. */ disarm: () => void /** Cancels the pending open and forgets which folders already opened. Call when the drag ends. */ @@ -60,11 +60,14 @@ export function useSpringLoadedFolder({ delayMs = SPRING_LOAD_DELAY_MS, }: UseSpringLoadedFolderOptions): SpringLoadedFolder { const timerRef = useRef | null>(null) - /** Folder the timer is currently counting down for, so re-arming it is a no-op. */ - const armedFolderIdRef = useRef(null) + /** + * Folder the timer is counting down for, so re-arming it is a no-op. `undefined` means + * nothing is armed — `null` is a real destination here, the workspace root. + */ + const armedFolderIdRef = useRef(undefined) /** Folders already opened during this drag; each may only spring once. */ - const openedFolderIdsRef = useRef | null>(null) - const openedFolderIds = (openedFolderIdsRef.current ??= new Set()) + const openedFolderIdsRef = useRef | null>(null) + const openedFolderIds = (openedFolderIdsRef.current ??= new Set()) const onSpringOpenRef = useRef(onSpringOpen) onSpringOpenRef.current = onSpringOpen @@ -72,14 +75,14 @@ export function useSpringLoadedFolder({ const clearTimer = useCallback(() => { if (timerRef.current !== null) clearTimeout(timerRef.current) timerRef.current = null - armedFolderIdRef.current = null + armedFolderIdRef.current = undefined }, []) /** A drag can outlive the list that started it; never leave a timer pointing at a dead tree. */ useEffect(() => clearTimer, [clearTimer]) const arm = useCallback( - (folderId: string) => { + (folderId: string | null) => { if (armedFolderIdRef.current === folderId) return /** @@ -93,7 +96,7 @@ export function useSpringLoadedFolder({ armedFolderIdRef.current = folderId timerRef.current = setTimeout(() => { timerRef.current = null - armedFolderIdRef.current = null + armedFolderIdRef.current = undefined /** Read before the add: an empty set means nothing has opened in this drag yet. */ const isFirstOpenOfDrag = openedFolderIds.size === 0 openedFolderIds.add(folderId) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts index eaa02307577..620539fd6b5 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/index.ts @@ -1,4 +1,5 @@ export type { + BreadcrumbDropConfig, BreadcrumbEditing, BreadcrumbItem, DropdownOption, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index a7555ffc5ce..dcd0a512587 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -2,6 +2,7 @@ import { type ComponentType, + type DragEvent, Fragment, forwardRef, memo, @@ -61,6 +62,12 @@ export interface BreadcrumbEditing { export interface BreadcrumbItem { label: string + /** + * The folder this crumb navigates to (`null` is the workspace root). Supplying it makes the + * crumb a drag destination: hovering it mid-drag walks back up the tree, and releasing files + * the drag there. Omit on a crumb that is not a folder, such as a trailing detail segment. + */ + folderId?: string | null icon?: React.ElementType onClick?: () => void dropdownItems?: DropdownOption[] @@ -95,6 +102,19 @@ export interface ResourceAction { disabled?: boolean } +/** + * Makes breadcrumb crumbs drag destinations, so a drag can walk back up the tree it walked + * into. Hovering a crumb navigates to it after the same delay a folder row uses, and releasing + * on one files the drag there — the counterpart to spring-loading, which only ever goes deeper. + */ +export interface BreadcrumbDropConfig { + /** Index of the crumb currently under the drag, or `null`. Indexed because `null` is a folder. */ + activeIndex: number | null + onDragOver: (e: DragEvent, folderId: string | null, index: number) => void + onDragLeave: (e: DragEvent, index: number) => void + onDrop: (e: DragEvent, folderId: string | null) => void +} + interface ResourceHeaderProps { icon?: React.ElementType title?: string @@ -109,6 +129,7 @@ interface ResourceHeaderProps { * in `actions`; never stuff primary actions in here. */ aside?: ReactNode + breadcrumbDrop?: BreadcrumbDropConfig } export const ResourceHeader = memo(function ResourceHeader({ @@ -117,6 +138,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, actions, aside, + breadcrumbDrop, }: ResourceHeaderProps) { const headerRef = useRef(null) /** @@ -164,6 +186,22 @@ export const ResourceHeader = memo(function ResourceHeader({ */ const showLocationPopover = LocationIcon != null + /** + * Only a crumb that names a folder is a destination; a trailing detail segment + * has no `folderId` and stays inert. + */ + const crumbDrag = + breadcrumbDrop && crumb.folderId !== undefined + ? { + isActive: breadcrumbDrop.activeIndex === i, + onDragOver: (e: DragEvent) => + breadcrumbDrop.onDragOver(e, crumb.folderId as string | null, i), + onDragLeave: (e: DragEvent) => breadcrumbDrop.onDragLeave(e, i), + onDrop: (e: DragEvent) => + breadcrumbDrop.onDrop(e, crumb.folderId as string | null), + } + : undefined + return ( {i > 0 && ( @@ -177,6 +215,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs={breadcrumbs} className={segmentClassName} veilBoundaryRef={headerRef} + drag={crumbDrag} /> ) : ( )} @@ -262,6 +302,13 @@ function getBreadcrumbSegmentClassName( return 'min-w-0 flex-[0_1_auto] max-w-[min(32rem,55vw)]' } +/** + * A crumb receiving a drag. Same neutral tint and hairline the list rows use, so "release + * here" reads identically whether the destination is a row, the list body, or a crumb. + */ +const BREADCRUMB_DROP_CLASS = + 'bg-[var(--surface-4)] outline outline-1 outline-[var(--text-subtle)] outline-offset-[-1px]' + interface BreadcrumbSegmentProps { icon?: React.ElementType label: string @@ -269,6 +316,13 @@ interface BreadcrumbSegmentProps { dropdownItems?: DropdownOption[] editing?: BreadcrumbEditing className?: string + /** Drag handlers plus the active flag, when this crumb is a drag destination. */ + drag?: { + isActive: boolean + onDragOver: (e: DragEvent) => void + onDragLeave: (e: DragEvent) => void + onDrop: (e: DragEvent) => void + } } const BreadcrumbSegment = memo(function BreadcrumbSegment({ @@ -278,6 +332,7 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ dropdownItems, editing, className, + drag, }: BreadcrumbSegmentProps) { const { ref: labelRef, node: labelNode, isOverflowing } = useIsOverflowing() const { state: tooltipState, handlers: tooltipHandlers } = useFloatingTooltip((target) => @@ -345,8 +400,11 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ From 7c2d2a94bcfb783ac2b9e5f6e95544da1eb5f39a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 15 Aug 2026 11:43:09 -0700 Subject: [PATCH 11/11] fix(files): keep the view in a spring-opened folder when an OS upload lands there --- apps/sim/app/workspace/[workspaceId]/files/files.tsx | 6 ++++++ apps/sim/lib/knowledge/application/bulk.ts | 7 +++++++ apps/sim/lib/table/application/bulk.ts | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/files/files.tsx index 64a12e4e18d..6e43ec3d53f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/files.tsx @@ -1147,6 +1147,12 @@ export function Files() { const handleDrop = async (e: React.DragEvent) => { if (!hasExternalFiles(e.dataTransfer)) return e.preventDefault() + /** + * The upload lands in the folder currently open, so the view must stay there. Without this + * the window-level teardown treats the drag as unconsumed and returns to the folder it + * began in — pulling the user out of the folder they just spring-opened to receive it. + */ + springNav.markDropHandled() dragCounterRef.current = 0 setIsDraggingOver(false) const dropped = Array.from(e.dataTransfer.files) diff --git a/apps/sim/lib/knowledge/application/bulk.ts b/apps/sim/lib/knowledge/application/bulk.ts index e7b0639a3a4..3f8aaf2d776 100644 --- a/apps/sim/lib/knowledge/application/bulk.ts +++ b/apps/sim/lib/knowledge/application/bulk.ts @@ -200,6 +200,13 @@ export const bulkMoveKnowledgeItems = defineAuthorizedKnowledgeUseCase({ * selected folders plus their descendants, so this rejects both "into itself" and "into its * own child" before anything is written. Without it the resources move, the folders then fail * their cycle check, and the caller is left with a half-applied selection. + * + * This is a fast-fail optimization, not the enforcement point. It reads a snapshot taken + * outside the folder mutation lock, so a concurrent reparent can invalidate it between the + * check and the write. The invariant itself is enforced where it must be — `updateFolder` + * re-checks `wouldCreateFolderCycle` inside `acquireFolderMutationLock`, so a cycle is never + * created. Losing that race costs a reported per-folder `failed` alongside resources that + * did move, which is the batch's documented `sequential_best_effort` outcome, not corruption. */ if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { throw new OrchestrationError( diff --git a/apps/sim/lib/table/application/bulk.ts b/apps/sim/lib/table/application/bulk.ts index cc668daf5a2..5b895d92662 100644 --- a/apps/sim/lib/table/application/bulk.ts +++ b/apps/sim/lib/table/application/bulk.ts @@ -227,6 +227,13 @@ export const bulkMoveTables = defineAuthorizedTableUseCase({ * selected folders plus their descendants, so this rejects both "into itself" and "into its * own child" before anything is written. Without it the tables move, the folders then fail * their cycle check, and the caller is left with a half-applied selection. + * + * This is a fast-fail optimization, not the enforcement point. It reads a snapshot taken + * outside the folder mutation lock, so a concurrent reparent can invalidate it between the + * check and the write. The invariant itself is enforced where it must be — `updateFolder` + * re-checks `wouldCreateFolderCycle` inside `acquireFolderMutationLock`, so a cycle is never + * created. Losing that race costs a reported per-folder `failed` alongside resources that + * did move, which is the batch's documented `sequential_best_effort` outcome, not corruption. */ if (input.targetFolderId !== null && plan.covered.has(input.targetFolderId)) { throw new OrchestrationError(