Skip to content

Commit ef2b19c

Browse files
committed
fix(folders): address the review round and the full-PR audit
Reverts the workflow restore hook — it was the wrong fix - Greptile flagged that restoring a folder leaves schedules disabled and webhooks/chats inactive. The hook added last round routed workflows through `restoreWorkflow` to address it, but `restoreDependents` ALREADY clears exactly those columns, in bulk, inside the restore transaction and matched on the archive timestamp. The hook bought nothing and cost a per-workflow read/transaction/read OUTSIDE that transaction — roughly 1600 round trips for a folder of 200 workflows — plus a window where the workflows were active and the folder was not, and it made `restoreDependents` dead code - What no restore path can undo is the state archive OVERWRITES: `status: 'disabled'` with `nextRunAt` cleared, `isActive: false`. Archive does not record what those were, so restoring them to a constant would re-enable a schedule the user had disabled and re-run a completed one. Left explicit — redeploy re-activates a schedule — matching deployment state, which restore also deliberately leaves off. Documented on the config rather than guessed - Kept the genuine bug the review surfaced underneath it: `restoreWorkflow` un-archived every dependent by `workflowId` alone, resurrecting a webhook or chat the user had archived days earlier. Now matched on the workflow's own `archivedAt`, which is the semantics this feature documents Bugbot: orphaned knowledge bases vanished from every view - Knowledge filtered on a raw `folderId === currentFolderId` and never re-rooted a base whose folder is missing or archived, so a base restored on its own — or left behind by a partial cascade — was invisible everywhere. Tables already fell those back to the root A dead `?folderId=` is now healed instead of being a dead end - A bookmark to a deleted folder rendered the root title over an empty grid, hid everything actually at the root, and — worse — left every create/upload action targeting the dead id, filing new resources somewhere nothing could reach. `useFolderNavigation` clears it once the folder list resolves, which fixes Files, Knowledge, and Tables at once Parity gaps found by auditing against Files - Tables sorted folders newest-first on a clean URL while Files and Knowledge sorted A→Z: its sort params are defaulted, so the raw column was never null. Reads `activeSort` now - On Tables you could not delete the folder you were standing in — its own row is not in the list, and the crumb menu offered only Rename, which also made the step-out branch unreachable dead code - A failed knowledge-base move was logged and never surfaced, so a rejected move — from the submenu or from a drag — looked like nothing happened - A drag begun in another mount of the page could never be dropped: `onDragOver` bailed before `preventDefault`, so no drop event ever fired and the `dataTransfer` fallback in `onDrop` was unreachable. External/foreign drags are still ignored, so an OS file dropped on the list cannot navigate the tab away Files: a folder cycle crashed the page - The folder-size roll-up recursed with no `visited` guard, so a parent/child cycle in the cached tree — reachable through the optimistic folder-move write — recursed until the stack blew and the page went blank. Also indexes children once instead of re-scanning per node Migration 0274 is now atomic - 0272 ends with an embedded COMMIT for its CONCURRENTLY index builds, so this file is not guaranteed to run inside drizzle's batch transaction. Wrapped in a DO block so the parking pass cannot commit without the reconcile, which would leave every mirrored folder holding a placeholder name - Verified against production, where 0272 has NOT yet run, so the two apply back to back and this is a no-op reconcile: 1,493 legacy file folders, 0 active name duplicates, 0 orphaned or cross-workspace parents, and 0 id collisions with `workflow_folder` — so the insert cannot trip the unique index, the FKs, or the resource-type trigger Smaller corrections - Knowledge indexed its members for the owner comparator instead of two linear scans per comparison, and logs a load failure from an effect rather than the render body - Files uses the shared root sentinel instead of a bare `'__root__'` in two places - `restore_resource` documents that its two new folder types are unreachable until the enum in the copilot service's tool catalog — a different repository — widens - Corrected a forking comment that still described file folders as a separate table
1 parent 781a768 commit ef2b19c

14 files changed

Lines changed: 268 additions & 119 deletions

File tree

apps/sim/app/api/table/[tableId]/route.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ describe('PATCH /api/table/[tableId] folder moves', () => {
9797
expect(mockFindActiveFolder).toHaveBeenCalledWith('folder-1', 'workspace-1', 'table')
9898
expect(mockMoveTableToFolder).toHaveBeenCalledWith(
9999
'tbl_1',
100+
'workspace-1',
100101
'folder-1',
101102
expect.any(String),
102103
'user-1'
@@ -113,7 +114,13 @@ describe('PATCH /api/table/[tableId] folder moves', () => {
113114

114115
expect(response.status).toBe(200)
115116
expect(mockFindActiveFolder).not.toHaveBeenCalled()
116-
expect(mockMoveTableToFolder).toHaveBeenCalledWith('tbl_1', null, expect.any(String), 'user-1')
117+
expect(mockMoveTableToFolder).toHaveBeenCalledWith(
118+
'tbl_1',
119+
'workspace-1',
120+
null,
121+
expect.any(String),
122+
'user-1'
123+
)
117124
})
118125

119126
it('leaves placement untouched when folderId is omitted', async () => {

apps/sim/app/api/table/[tableId]/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,13 @@ export const PATCH = withRouteHandler(
196196
) {
197197
return NextResponse.json({ error: 'Folder not found in this workspace' }, { status: 404 })
198198
}
199-
await moveTableToFolder(tableId, validated.folderId, requestId, authResult.userId)
199+
await moveTableToFolder(
200+
tableId,
201+
table.workspaceId,
202+
validated.folderId,
203+
requestId,
204+
authResult.userId
205+
)
200206
}
201207

202208
// Re-read so the response reflects both a rename and a lock change.

apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-navigation.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useMemo } from 'react'
3+
import { useCallback, useEffect, useMemo } from 'react'
44
import { useQueryStates } from 'nuqs'
55
import type { ServedFolderResourceType } from '@/lib/api/contracts/folders'
66
import {
@@ -67,6 +67,23 @@ export function useFolderNavigation({
6767
return byId
6868
}, [folders])
6969

70+
/**
71+
* Heals a `?folderId=` that no longer resolves — a bookmark to a folder since deleted, or a
72+
* link from someone whose workspace it was not.
73+
*
74+
* Without this the page is a dead end rather than a mistake: the header falls back to the
75+
* root title while the list still filters on the dead id, so the user sees a page that looks
76+
* like the root but is empty and hides everything actually at the root. Worse, the create
77+
* and upload actions keep targeting that id, so a new resource is filed somewhere nothing
78+
* can reach.
79+
*
80+
* Waits for `isLoading` so an empty index mid-fetch never evicts a perfectly good id.
81+
*/
82+
useEffect(() => {
83+
if (isLoading || !currentFolderId || folderById.has(currentFolderId)) return
84+
void setFolderParams({ folderId: null })
85+
}, [isLoading, currentFolderId, folderById, setFolderParams])
86+
7087
const breadcrumbs = useMemo(() => {
7188
if (!currentFolderId) return EMPTY_FOLDERS
7289

apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,21 @@ export function useFolderRowDragDrop({
128128
},
129129
onDragOver: (e: DragEvent<HTMLDivElement>, rowId) => {
130130
const sourceRowId = draggedRowIdRef.current
131-
if (!sourceRowId || isInvalidDropTarget(rowId, sourceRowId)) return
131+
if (sourceRowId) {
132+
if (isInvalidDropTarget(rowId, sourceRowId)) return
133+
} else if (!e.dataTransfer.types.includes(DRAG_ROW_MIME)) {
134+
/**
135+
* No local source and no payload of ours — an external or foreign drag. Returning
136+
* without `preventDefault` leaves the browser's default handling in place, which is
137+
* what stops a dropped OS file from navigating the tab away from the app.
138+
*/
139+
return
140+
}
141+
/**
142+
* `dataTransfer.getData` is empty during dragover by design (the drag data store is
143+
* protected until drop), so a drag that began in another mount of this page can only be
144+
* recognised by its MIME type here. `onDrop` re-checks validity with the real payload.
145+
*/
132146
e.preventDefault()
133147
e.stopPropagation()
134148
e.dataTransfer.dropEffect = 'move'

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ import {
6262
timeCell,
6363
} from '@/app/workspace/[workspaceId]/components'
6464
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders'
65+
import {
66+
parseMoveOptionValue,
67+
ROOT_MOVE_OPTION_VALUE,
68+
} from '@/app/workspace/[workspaceId]/components/folders'
6569
import { FilesActionBar } from '@/app/workspace/[workspaceId]/files/components/action-bar'
6670
import { DeleteConfirmModal } from '@/app/workspace/[workspaceId]/files/components/delete-confirm-modal'
6771
import { FileRowContextMenu } from '@/app/workspace/[workspaceId]/files/components/file-row-context-menu'
@@ -378,12 +382,34 @@ export function Files() {
378382
directSize.set(file.folderId, (directSize.get(file.folderId) ?? 0) + file.size)
379383
}
380384
}
385+
/**
386+
* Children indexed once rather than re-scanning `folders` per node — the roll-up visits
387+
* every folder, so the filter made this quadratic.
388+
*/
389+
const childrenByParent = new Map<string, WorkspaceFileFolderApi[]>()
390+
for (const folder of folders) {
391+
if (!folder.parentId) continue
392+
const siblings = childrenByParent.get(folder.parentId)
393+
if (siblings) siblings.push(folder)
394+
else childrenByParent.set(folder.parentId, [folder])
395+
}
396+
381397
const totalSize = new Map<string, number>()
398+
/**
399+
* `visiting` terminates a parent/child cycle. The optimistic folder-move write can produce
400+
* one in cache, and without the guard this recurses until the stack blows and takes the
401+
* whole page down — the same guard the shared folder helpers carry.
402+
*/
403+
const visiting = new Set<string>()
382404
const getTotal = (folderId: string): number => {
383-
if (totalSize.has(folderId)) return totalSize.get(folderId)!
384-
const children = folders.filter((f) => f.parentId === folderId)
405+
const cached = totalSize.get(folderId)
406+
if (cached !== undefined) return cached
407+
if (visiting.has(folderId)) return 0
408+
visiting.add(folderId)
385409
const size =
386-
(directSize.get(folderId) ?? 0) + children.reduce((s, c) => s + getTotal(c.id), 0)
410+
(directSize.get(folderId) ?? 0) +
411+
(childrenByParent.get(folderId) ?? []).reduce((sum, child) => sum + getTotal(child.id), 0)
412+
visiting.delete(folderId)
387413
totalSize.set(folderId, size)
388414
return size
389415
}
@@ -1380,7 +1406,7 @@ export function Files() {
13801406

13811407
const handleContextMenuMove = useCallback(
13821408
async (optionValue: string) => {
1383-
const targetFolderId = optionValue === '__root__' ? null : optionValue
1409+
const targetFolderId = parseMoveOptionValue(optionValue)
13841410
try {
13851411
await moveItems.mutateAsync({
13861412
workspaceId,
@@ -1767,7 +1793,7 @@ export function Files() {
17671793
.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))
17681794
.map((f) => ({ value: f.id, label: f.name, children: buildSubtree(f.id) }))
17691795

1770-
return [{ value: '__root__', label: 'Files', children: [] }, ...buildSubtree(null)]
1796+
return [{ value: ROOT_MOVE_OPTION_VALUE, label: 'Files', children: [] }, ...buildSubtree(null)]
17711797
}, [folders, selectedFolderIds, descendantFolderIdsByFolderId])
17721798

17731799
const sortConfig: SortConfig = useMemo(

apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
6161
import { useCreateFolder, useDeleteFolderMutation, useUpdateFolder } from '@/hooks/queries/folders'
6262
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
6363
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
64-
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
64+
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
6565
import { useDebounce } from '@/hooks/use-debounce'
6666
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
6767
import { useInlineRename } from '@/hooks/use-inline-rename'
@@ -166,6 +166,15 @@ export function Knowledge() {
166166

167167
const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId)
168168
const { data: members } = useWorkspaceMembersQuery(workspaceId)
169+
/**
170+
* Indexed once: `ownerCell` resolves a member per row, so passing the raw array makes the
171+
* owner column O(rows x members) on every rebuild. Tables already does this.
172+
*/
173+
const membersById = useMemo(() => {
174+
const byId = new Map<string, WorkspaceMember>()
175+
for (const member of members ?? []) byId.set(member.userId, member)
176+
return byId
177+
}, [members])
169178
/**
170179
* Two pin lookups: a folder pins under `resourceType: 'folder'`, which is a different pin
171180
* namespace from the knowledge bases it contains, so one set cannot answer for both.
@@ -175,15 +184,23 @@ export function Knowledge() {
175184
const pinItem = usePinItem()
176185
const unpinItem = useUnpinItem()
177186

178-
if (error) {
179-
logger.error('Failed to load knowledge bases:', error)
180-
}
187+
useEffect(() => {
188+
if (error) logger.error('Failed to load knowledge bases:', error)
189+
}, [error])
190+
181191
const userPermissions = useUserPermissionsContext()
182192

183193
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
184194
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
185195

186-
const { currentFolderId, setCurrentFolderId, breadcrumbs, folders } = useFolderNavigation({
196+
const {
197+
currentFolderId,
198+
setCurrentFolderId,
199+
breadcrumbs,
200+
folders,
201+
folderById,
202+
isLoading: foldersLoading,
203+
} = useFolderNavigation({
187204
resourceType: FOLDER_RESOURCE_TYPE,
188205
workspaceId,
189206
})
@@ -411,9 +428,19 @@ export function Knowledge() {
411428
}, [folders, currentFolderId, debouncedSearchQuery, activeSort, pinnedFolderIds])
412429

413430
const processedKBs = useMemo(() => {
414-
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter(
415-
(kb) => (kb.folderId ?? null) === currentFolderId
416-
)
431+
/**
432+
* A `folderId` that no longer names an active folder — a base restored on its own out of
433+
* Recently Deleted while its folder stayed archived, or a cascade that failed partway —
434+
* would otherwise match no level at all and leave the base unreachable from every view.
435+
* Fall it back to the root instead. Skipped while the folder list is still loading, when
436+
* an empty index would transiently drag every base to the root.
437+
*/
438+
let result = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery).filter((kb) => {
439+
const folderId = kb.folderId ?? null
440+
const effectiveFolderId =
441+
foldersLoading || !folderId || folderById.has(folderId) ? folderId : null
442+
return effectiveFolderId === currentFolderId
443+
})
417444

418445
if (connectorFilter.length > 0) {
419446
result = result.filter((kb) => {
@@ -469,8 +496,8 @@ export function Knowledge() {
469496
cmp = (a.connectorTypes?.length ?? 0) - (b.connectorTypes?.length ?? 0)
470497
break
471498
case 'owner':
472-
cmp = (members?.find((m) => m.userId === a.userId)?.name ?? '').localeCompare(
473-
members?.find((m) => m.userId === b.userId)?.name ?? ''
499+
cmp = (membersById.get(a.userId)?.name ?? '').localeCompare(
500+
membersById.get(b.userId)?.name ?? ''
474501
)
475502
break
476503
}
@@ -479,12 +506,14 @@ export function Knowledge() {
479506
}, [
480507
knowledgeBases,
481508
currentFolderId,
509+
folderById,
510+
foldersLoading,
482511
debouncedSearchQuery,
483512
connectorFilter,
484513
contentFilter,
485514
ownerFilter,
486515
activeSort,
487-
members,
516+
membersById,
488517
pinnedBaseIds,
489518
])
490519

@@ -497,7 +526,7 @@ export function Knowledge() {
497526
tokens: { label: EMPTY_CELL_PLACEHOLDER },
498527
connectors: { label: EMPTY_CELL_PLACEHOLDER },
499528
created: timeCell(folder.createdAt),
500-
owner: ownerCell(folder.userId, members),
529+
owner: ownerCell(folder.userId, membersById),
501530
updated: timeCell(folder.updatedAt),
502531
},
503532
})
@@ -521,14 +550,14 @@ export function Knowledge() {
521550
},
522551
connectors: connectorCell(kb.connectorTypes),
523552
created: timeCell(kb.createdAt),
524-
owner: ownerCell(kb.userId, members),
553+
owner: ownerCell(kb.userId, membersById),
525554
updated: timeCell(kb.updatedAt),
526555
},
527556
}
528557
})
529558

530559
return [...folderRows, ...knowledgeBaseRows]
531-
}, [visibleFolders, processedKBs, members, pinnedFolderIds, pinnedBaseIds])
560+
}, [visibleFolders, processedKBs, membersById, pinnedFolderIds, pinnedBaseIds])
532561

533562
/**
534563
* Rename is layered over the built rows rather than folded into the builder above, so a
@@ -783,6 +812,7 @@ export function Knowledge() {
783812
await updateKnowledgeBaseMutation({ knowledgeBaseId, updates: { folderId } })
784813
} catch (moveError) {
785814
logger.error('Failed to move knowledge base', moveError)
815+
toast.error(getErrorMessage(moveError, 'Failed to move knowledge base'))
786816
}
787817
},
788818
[updateKnowledgeBaseMutation]

apps/sim/app/workspace/[workspaceId]/tables/tables.tsx

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,14 @@ export function Tables() {
133133
workspaceId,
134134
})
135135

136-
if (error) {
137-
logger.error('Failed to load tables:', error)
138-
}
136+
/**
137+
* Logged from an effect, not the render body: a render-phase log fires again on every
138+
* re-render while the error persists, and on each of React's double renders in dev.
139+
*/
140+
useEffect(() => {
141+
if (error) logger.error('Failed to load tables:', error)
142+
}, [error])
143+
139144
const deleteTable = useDeleteTable(workspaceId)
140145
const renameTable = useRenameTable(workspaceId)
141146
const createTable = useCreateTable(workspaceId)
@@ -268,19 +273,25 @@ export function Tables() {
268273
const bPinned = pinnedFolderIds.has(b.id)
269274
if (aPinned !== bPinned) return aPinned ? -1 : 1
270275

271-
// Folders carry none of the table-specific columns, so `columns`/`rows`/
272-
// `owner` fall back to name rather than producing an arbitrary order.
276+
/**
277+
* Read from `activeSort`, not the raw params: `tablesSortParams` is defaulted, so
278+
* `sortColumn` is never null and folders would sort newest-first on a clean URL while
279+
* Files and Knowledge sort them A→Z. Folders also carry none of the table-specific
280+
* columns, so `columns`/`rows`/`owner` fall back to name rather than an arbitrary order.
281+
*/
282+
const col = activeSort?.column ?? 'name'
283+
const dir = activeSort?.direction ?? 'asc'
273284
let cmp = 0
274-
if (sortColumn === 'created') {
285+
if (col === 'created') {
275286
cmp = a.createdAt.getTime() - b.createdAt.getTime()
276-
} else if (sortColumn === 'updated') {
287+
} else if (col === 'updated') {
277288
cmp = a.updatedAt.getTime() - b.updatedAt.getTime()
278289
} else {
279290
cmp = a.name.localeCompare(b.name)
280291
}
281-
return sortDirection === 'asc' ? cmp : -cmp
292+
return dir === 'asc' ? cmp : -cmp
282293
})
283-
}, [folders, currentFolderId, debouncedSearchTerm, sortColumn, sortDirection, pinnedFolderIds])
294+
}, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds])
284295

285296
const processedTables = useMemo(() => {
286297
const query = debouncedSearchTerm.trim().toLowerCase()
@@ -452,6 +463,18 @@ export function Tables() {
452463
disabled: !canEdit,
453464
onClick: () => breadcrumbRename.startRename(folder.id, folder.name),
454465
},
466+
{
467+
label: 'Delete',
468+
disabled: !canEdit,
469+
/**
470+
* The only way to delete the folder you are inside — its own row is not in the list.
471+
* This is what makes the step-out in `handleDeleteFolder` reachable.
472+
*/
473+
onClick: () => {
474+
setActiveFolder(folder)
475+
setIsDeleteFolderDialogOpen(true)
476+
},
477+
},
455478
]
456479
}, [currentFolderId, folderById, canEdit, breadcrumbRename.startRename])
457480

apps/sim/ee/workspace-forking/lib/copy/copy-workflows.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,9 @@ interface ResolveForkFolderMappingParams {
4545
* Source folder ids that will directly hold copied content (workflows); null entries
4646
* (root-placed content) are ignored. A source folder is copied into the target only when
4747
* its subtree contains at least one of these, so a fork/sync never creates folders that
48-
* would end up empty. Copied workspace FILES never influence this set: they live in the
49-
* separate `workspace_file_folders` entity and are flattened to root by the copy.
48+
* would end up empty. Copied workspace FILES never influence this set: their folders are a
49+
* separate tree (`folder` rows with `resourceType = 'file'`, which this copy only ever reads
50+
* as `'workflow'`) and are flattened to root by the copy.
5051
*/
5152
contentFolderIds: ReadonlyArray<string | null>
5253
}

apps/sim/lib/api/contracts/folders.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export type FolderResourceType = z.output<typeof folderResourceTypeSchema>
2121
* never sends the field, working against a new pod. A value that is present but not in the
2222
* enum is REJECTED with a 400, never silently coerced to `'workflow'`: coercing would file a
2323
* knowledge-base folder into the workflow tree, where the Knowledge page can never see it
24-
* again. This is the deploy-ordering contract — see the note in the PR description.
24+
* again. This is the deploy-ordering contract, pinned by `folders.test.ts`.
2525
*/
2626
export const servedFolderResourceTypeSchema = z
2727
.enum(['workflow', 'knowledge_base', 'table'], {

0 commit comments

Comments
 (0)