Skip to content

Commit e3fb656

Browse files
committed
feat(files): folder operations for the File block, and folders as scope
Adds folder operations to file_v5 and to the agent tool surface, and makes a folder a scope on the file operations that already existed rather than a second set of operations beside them. New operations: List, Create Folder, Move Folder, Delete Folder, Restore Folder, and Move File. List answers "what is in here" — subfolders and files together, direct children by default, the whole subtree under Recursive, subject to Max Depth and Search. Its entries are a discriminated union on kind, so a consumer narrows before reaching for the fields only one side has, and the listing is capped with a truncated flag rather than being unbounded now that it includes files. Read, Get Content, Compress and Append gain an optional Folder above their file picker. It narrows what the picker offers, and on the three read operations it also stands for that folder's files when none are picked — resolved when the workflow runs, so a file added later is included. Append only narrows: a folder is not something you can append to, so it shapes the options and does not travel. Write gains a folder destination, placed above File Name because it names where before it names what. Every folder field is one single-select tree control paired with a manual entry. The picker cannot hold a reference expression — `<` autocomplete comes from TagDropdown, which scans for the last `<` before the cursor and so only exists on a text surface — which is why the pair exists rather than being a convenience. Root is the absence of a selection rather than a row, since a root row would be meaningless on create and wrong on delete. Path handling is the part most worth reviewing. Two spellings circulate: the stored display path, which backslash-escapes a slash inside a folder name, and the canonical percent-encoded path the tools take. A folder genuinely named "Q3/Q4" is one level in both and two if either is split on "/", so folderPathSegments decodes by the leading slash the canonical form carries, and resolveFolderIdsForPaths, isFileInFolderScope and selectDirectoryEntries are pure and tested against exactly that case. Delete Folder keeps its recursive flag as a guard rather than a scope: without it, deleting a non-empty folder fails, and it is user-only so a model asked to clean up a folder cannot set it on a guess.
1 parent ad83796 commit e3fb656

42 files changed

Lines changed: 3313 additions & 188 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/sim/app/api/tools/file/manage/route.ts

Lines changed: 260 additions & 24 deletions
Large diffs are not rendered by default.

apps/sim/app/api/v2/files/folders/route.ts

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import {
55
v2RelocateFileFolderContract,
66
} from '@/lib/api/contracts/v2/files'
77
import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes'
8-
import { buildFolderPath, parentFolderPath, parseFolderPath } from '@/lib/folders/paths'
98
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
109
import { fileOperations } from '@/lib/workspace-files/application/operations'
1110
import {
@@ -14,28 +13,11 @@ import {
1413
listWorkspaceFileFoldersOperation,
1514
updateWorkspaceFileFolderOperation,
1615
} from '@/lib/workspace-files/application/workspace-file-folders'
17-
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
16+
import { toWorkspaceFileFolderPathView } from '@/lib/workspace-files/folder-display-path'
1817

1918
export const dynamic = 'force-dynamic'
2019
export const revalidate = 0
2120

22-
function toV2Folder(folder: { name: string; path: string; createdAt: Date; updatedAt: Date }) {
23-
const segments = folder.path.startsWith('/')
24-
? parseFolderPath(folder.path)
25-
: parseWorkspaceFileFolderDisplayPath(folder.path)
26-
if (segments.at(-1) !== folder.name) {
27-
throw new Error('Workspace file folder path does not match its folder name')
28-
}
29-
const path = buildFolderPath(segments)
30-
return {
31-
name: folder.name,
32-
path,
33-
parentPath: parentFolderPath(path),
34-
createdAt: folder.createdAt.toISOString(),
35-
updatedAt: folder.updatedAt.toISOString(),
36-
}
37-
}
38-
3921
export const GET = defineV2JsonRoute({
4022
contract: v2ListFileFoldersContract,
4123
auth: v2ApiKeyAuth,
@@ -50,7 +32,10 @@ export const GET = defineV2JsonRoute({
5032
sortOrder: query.sortOrder,
5133
}),
5234
useCase: listWorkspaceFileFoldersOperation,
53-
present: ({ folders }) => ({ data: folders.map(toV2Folder), nextCursor: null }),
35+
present: ({ folders }) => ({
36+
data: folders.map(toWorkspaceFileFolderPathView),
37+
nextCursor: null,
38+
}),
5439
})
5540

5641
export const POST = defineV2JsonRoute({
@@ -61,7 +46,7 @@ export const POST = defineV2JsonRoute({
6146
errorPolicy: v2FileErrorPolicies.default,
6247
mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }),
6348
useCase: createWorkspaceFileFolderOperation,
64-
present: ({ folder }) => ({ data: toV2Folder(folder) }),
49+
present: ({ folder }) => ({ data: toWorkspaceFileFolderPathView(folder) }),
6550
})
6651

6752
export const PATCH = defineV2JsonRoute({
@@ -76,7 +61,7 @@ export const PATCH = defineV2JsonRoute({
7661
destinationPath: body.destinationPath,
7762
}),
7863
useCase: updateWorkspaceFileFolderOperation,
79-
present: ({ folder }) => ({ data: toV2Folder(folder) }),
64+
present: ({ folder }) => ({ data: toWorkspaceFileFolderPathView(folder) }),
8065
})
8166

8267
export const DELETE = defineV2JsonRoute({

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx

Lines changed: 92 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { isApiClientError } from '@/lib/api/client/errors'
1414
import { requestJson } from '@/lib/api/client/request'
1515
import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer'
1616
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
17+
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
18+
import { isFileInFolderScope } from '@/lib/workspace-files/folder-path-selection'
1719
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
1820
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
1921
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
@@ -45,6 +47,11 @@ interface FileUploadProps {
4547
isPreview?: boolean
4648
previewValue?: any | null
4749
disabled?: boolean
50+
/**
51+
* A sibling folder field that narrows what this picker offers, and the switch
52+
* saying whether that scope descends. See `SubBlockConfig.folderScope`.
53+
*/
54+
folderScope?: { fieldId: string; recursiveFieldId?: string }
4855
/**
4956
* Controlled value. When `onValueChange` is provided the component reads from
5057
* this prop and writes through `onValueChange` instead of the subblock store,
@@ -55,12 +62,56 @@ interface FileUploadProps {
5562
onValueChange?: (value: UploadedFile | UploadedFile[] | null) => void
5663
}
5764

65+
/**
66+
* Label for a workspace file, prefixed with its folder so two files sharing a
67+
* name are distinguishable.
68+
*
69+
* The stored folder path escapes a slash inside a folder name, so it is decoded
70+
* into segments rather than split — otherwise a folder named `Q3/Q4` reads as
71+
* two levels.
72+
*/
73+
function workspaceFileOptionLabel(file: { name: string; folderPath?: string | null }): string {
74+
if (!file.folderPath) return file.name
75+
try {
76+
return `${parseWorkspaceFileFolderDisplayPath(file.folderPath).join(' / ')} / ${file.name}`
77+
} catch {
78+
return file.name
79+
}
80+
}
81+
82+
/** Groups files by folder, then by name, so the list reads folder by folder. */
83+
function byFolderThenName(
84+
a: { name: string; folderPath?: string | null },
85+
b: { name: string; folderPath?: string | null }
86+
): number {
87+
const folderOrder = (a.folderPath ?? '').localeCompare(b.folderPath ?? '')
88+
return folderOrder !== 0 ? folderOrder : a.name.localeCompare(b.name)
89+
}
90+
5891
export interface UploadedFile {
5992
name: string
6093
path: string
6194
key?: string
6295
size: number
6396
type: string
97+
/**
98+
* Canonical workspace file id, present when the file was chosen from the
99+
* workspace rather than uploaded in place.
100+
*
101+
* Carrying it is what makes a chosen file resolvable to exactly one row. A
102+
* name alone is ambiguous the moment the same one exists in two folders, and
103+
* the reference resolver then falls back to the oldest match anywhere in the
104+
* workspace — so dropping the id here turned a precise choice into a guess.
105+
*
106+
* Optional, because an upload has no workspace id until it lands.
107+
*/
108+
id?: string
109+
/**
110+
* Folder of a chosen workspace file, as the stored backslash-escaped display
111+
* path (`a\/b` is one folder named `a/b`). Decode it with
112+
* `parseWorkspaceFileFolderDisplayPath` — never by splitting on `/`.
113+
*/
114+
folderPath?: string
64115
}
65116

66117
interface SingleFileSelectorProps {
@@ -180,6 +231,7 @@ export function FileUpload({
180231
isPreview = false,
181232
previewValue,
182233
disabled = false,
234+
folderScope,
183235
value: controlledValue,
184236
onValueChange,
185237
}: FileUploadProps) {
@@ -274,7 +326,40 @@ export function FileUpload({
274326
})
275327
}
276328

277-
const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
329+
/*
330+
* A sibling folder field narrows what this picker offers. Choosing a folder
331+
* means the run only touches that folder, so listing files from anywhere else
332+
* would let a selection be built that the operation then ignores — the picker
333+
* has to describe the same set the run will read.
334+
*
335+
* Falling back to this control's own id keeps the hook call unconditional for
336+
* a picker with no folder scope; its own value is never a folder path, so the
337+
* scope reads as absent.
338+
*/
339+
const [folderScopeValue] = useSubBlockValue<unknown>(blockId, folderScope?.fieldId ?? subBlockId)
340+
const [folderScopeRecursive] = useSubBlockValue<unknown>(
341+
blockId,
342+
folderScope?.recursiveFieldId ?? subBlockId
343+
)
344+
const folderScopePath =
345+
folderScope && typeof folderScopeValue === 'string' ? folderScopeValue.trim() : ''
346+
const folderScopeIncludesSubfolders =
347+
!folderScope?.recursiveFieldId ||
348+
folderScopeRecursive === undefined ||
349+
folderScopeRecursive === null ||
350+
folderScopeRecursive === '' ||
351+
folderScopeRecursive === true ||
352+
folderScopeRecursive === 'true'
353+
354+
const scopedWorkspaceFiles = folderScopePath
355+
? workspaceFiles.filter((workspaceFile) =>
356+
isFileInFolderScope(workspaceFile.folderPath, folderScopePath, {
357+
includeSubfolders: folderScopeIncludesSubfolders,
358+
})
359+
)
360+
: workspaceFiles
361+
362+
const availableWorkspaceFiles = scopedWorkspaceFiles.filter((workspaceFile) => {
278363
const existingFiles = Array.isArray(value) ? value : value ? [value] : []
279364

280365
const isAlreadySelected = existingFiles.some(
@@ -486,6 +571,8 @@ export function FileUpload({
486571
key: selectedFile.key,
487572
size: selectedFile.size,
488573
type: selectedFile.type,
574+
id: selectedFile.id,
575+
folderPath: selectedFile.folderPath ?? undefined,
489576
}
490577

491578
if (multiple) {
@@ -620,11 +707,11 @@ export function FileUpload({
620707
const comboboxOptions = useMemo(
621708
() => [
622709
{ label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked },
623-
...availableWorkspaceFiles.map((file) => {
710+
...[...availableWorkspaceFiles].sort(byFolderThenName).map((file) => {
624711
const isAccepted =
625712
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
626713
return {
627-
label: file.name,
714+
label: workspaceFileOptionLabel(file),
628715
value: file.id,
629716
// When cloud is required, local workspace files are also unpublishable.
630717
disabled: !isAccepted || cloudUploadBlocked,
@@ -638,11 +725,11 @@ export function FileUpload({
638725
const singleFileOptions = useMemo(
639726
() => [
640727
{ label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked },
641-
...workspaceFiles.map((file) => {
728+
...[...scopedWorkspaceFiles].sort(byFolderThenName).map((file) => {
642729
const isAccepted =
643730
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
644731
return {
645-
label: file.name,
732+
label: workspaceFileOptionLabel(file),
646733
value: file.id,
647734
disabled: !isAccepted || cloudUploadBlocked,
648735
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Reads a stored folder value into a canonical percent-encoded path.
3+
*
4+
* The picker stores a plain string, but the manual half of the pair is a text
5+
* field, so a reference like `<block.folderPath>` resolves to a string before it
6+
* gets here. A JSON array is tolerated because an earlier revision of this
7+
* control stored one, and reading only its first entry is closer to the intent
8+
* than discarding the value.
9+
*/
10+
export function readFolderPath(value: unknown): string {
11+
if (typeof value === 'string') {
12+
const trimmed = value.trim()
13+
if (!trimmed) return ''
14+
if (trimmed.startsWith('[')) {
15+
try {
16+
return readFolderPath(JSON.parse(trimmed))
17+
} catch {
18+
return trimmed
19+
}
20+
}
21+
return trimmed
22+
}
23+
if (Array.isArray(value)) {
24+
const first = value.find((entry) => typeof entry === 'string' && entry.length > 0)
25+
return typeof first === 'string' ? first : ''
26+
}
27+
return ''
28+
}

0 commit comments

Comments
 (0)