Skip to content

Commit c7d6a91

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 150bff4 commit c7d6a91

40 files changed

Lines changed: 3132 additions & 140 deletions

File tree

‎apps/docs/content/docs/integrations/file.mdx‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ Read workspace file objects from selected files or canonical workspace file IDs.
4444
| --------- | ---- | -------- | ----------- |
4545
| `fileId` | string | No | Canonical workspace file ID, or an array of canonical workspace file IDs. |
4646
| `fileInput` | file | No | Selected workspace file object. |
47+
| `folderPaths` | array | No | Folders whose files are included, as canonical percent-encoded paths, e.g. \["/Reports/Q3%20Results"\]. Nested folders are included by default, and the folders are read at run time, so a file added later is picked up. |
48+
| `includeSubfolders` | boolean | No | Whether nested folders are read too. Defaults to true; set false to take only the folders’ direct files. |
4749

4850
#### Output
4951

@@ -61,6 +63,8 @@ Extract the text content of one or more workspace files from selected file objec
6163
| --------- | ---- | -------- | ----------- |
6264
| `fileId` | string | No | Canonical workspace file ID, or an array of canonical workspace file IDs. |
6365
| `fileInput` | file | No | Selected workspace file object, or an array of file objects. |
66+
| `folderPaths` | array | No | Folders whose files are included, as canonical percent-encoded paths, e.g. \["/Reports/Q3%20Results"\]. Nested folders are included by default, and the folders are read at run time, so a file added later is picked up. |
67+
| `includeSubfolders` | boolean | No | Whether nested folders are read too. Defaults to true; set false to take only the folders’ direct files. |
6468

6569
#### Output
6670

@@ -125,6 +129,7 @@ Create a new workspace file, either from text content or from an existing file.
125129
| Parameter | Type | Required | Description |
126130
| --------- | ---- | -------- | ----------- |
127131
| `fileName` | string | No | File name \(e.g., "data.csv"\). Required when writing text; optional when storing a file, which keeps its own name unless this overrides it. If the name already exists, a numeric suffix is added automatically unless overwrite is enabled. |
132+
| `folderPath` | string | No | Folder to create the file in. Omit for the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
128133
| `content` | string | No | The text content to write to the file. Provide exactly one of content or fileInput. |
129134
| `fileInput` | file | No | An existing file to store in the workspace, such as one produced by an earlier tool. Use this for anything that is not text — PDFs, images, audio, archives. Provide exactly one of content or fileInput. |
130135
| `contentType` | string | No | MIME type for new files \(e.g., "text/plain"\). Auto-detected from the file extension, or taken from the stored file, if omitted. |
@@ -148,6 +153,7 @@ Append content to an existing workspace file. The file must already exist. Conte
148153
| Parameter | Type | Required | Description |
149154
| --------- | ---- | -------- | ----------- |
150155
| `fileName` | string | Yes | Name of an existing workspace file to append to. |
156+
| `folderPath` | string | No | Folder the file lives in. Naming it targets exactly one file when the same name exists in several folders. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
151157
| `content` | string | Yes | The text content to append to the file. |
152158

153159
#### Output
@@ -169,6 +175,8 @@ Compress one or more workspace files into a single .zip archive stored in the wo
169175
| --------- | ---- | -------- | ----------- |
170176
| `fileId` | string | No | Canonical workspace file ID, or an array of canonical workspace file IDs. |
171177
| `fileInput` | file | No | Selected workspace file object, or an array of file objects. |
178+
| `folderPaths` | array | No | Folders whose files are included, as canonical percent-encoded paths, e.g. \["/Reports/Q3%20Results"\]. Nested folders are included by default, and the folders are read at run time, so a file added later is picked up. |
179+
| `includeSubfolders` | boolean | No | Whether nested folders are read too. Defaults to true; set false to take only the folders’ direct files. |
172180
| `archiveName` | string | No | Name for the .zip archive \(e.g., "documents.zip"\). Defaults to the source file name when compressing a single file, otherwise "archive.zip". |
173181

174182
#### Output
@@ -223,4 +231,114 @@ Enable or disable the public share link for a workspace file, and set its access
223231
| `hasPassword` | boolean | Whether the share is password-protected |
224232
| `allowedEmails` | array | Allowed emails/domains for email or SSO access |
225233

234+
### List Files and Folders
235+
236+
List what is inside a workspace folder: its subfolders and its files together. Lists direct children by default; set Recursive to walk the whole subtree.
237+
238+
#### Input
239+
240+
| Parameter | Type | Required | Description |
241+
| --------- | ---- | -------- | ----------- |
242+
| `path` | string | No | Folder to list. Omit to list from the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
243+
| `recursive` | boolean | No | List everything beneath the path rather than only its direct children. Each entry carries its depth below the listed folder. |
244+
| `depth` | number | No | Deepest level to include when recursive, counted from the listed folder. 1 is direct children. |
245+
| `search` | string | No | Case-insensitive substring match against an entry name. Filters the result, so a deep match is still reported even when its parent folders do not match. |
246+
| `limit` | number | No | Most entries to return, 200 by default. A listing cut short comes back with truncated set. |
247+
248+
#### Output
249+
250+
| Parameter | Type | Description |
251+
| --------- | ---- | ----------- |
252+
| `path` | string | The folder that was listed. |
253+
| `entries` | array | What the folder holds. Each entry has kind "folder" or "file", a name, and its depth below the listed folder. A folder carries its own canonical path; a file carries its id, size, type, and the canonical path of the folder holding it. |
254+
| `truncated` | boolean | True when the limit cut the listing short, so more entries exist. |
255+
256+
### Create File Folder
257+
258+
Create a workspace file folder at a path. Parent folders are created as needed. Fails if a folder already exists at the path.
259+
260+
#### Input
261+
262+
| Parameter | Type | Required | Description |
263+
| --------- | ---- | -------- | ----------- |
264+
| `path` | string | Yes | Path of the folder to create. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
265+
266+
#### Output
267+
268+
| Parameter | Type | Description |
269+
| --------- | ---- | ----------- |
270+
| `folder` | object | The created folder, with its name, canonical path, parent path, and timestamps. |
271+
272+
### Move File Folder
273+
274+
Move or rename a workspace file folder by giving its full destination path. Everything inside the folder moves with it.
275+
276+
#### Input
277+
278+
| Parameter | Type | Required | Description |
279+
| --------- | ---- | -------- | ----------- |
280+
| `path` | string | Yes | Folder to move. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
281+
| `destinationPath` | string | Yes | Full path the folder should have afterwards. Renaming is a destination whose parent is unchanged. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
282+
283+
#### Output
284+
285+
| Parameter | Type | Description |
286+
| --------- | ---- | ----------- |
287+
| `folder` | object | The folder at its new path. |
288+
| `previousPath` | string | The path the folder had before the move. |
289+
290+
### Delete File Folder
291+
292+
Delete a workspace file folder. It moves to Recently deleted and can be brought back with Restore File Folder. Deleting a folder that still has contents requires the recursive option.
293+
294+
#### Input
295+
296+
| Parameter | Type | Required | Description |
297+
| --------- | ---- | -------- | ----------- |
298+
| `path` | string | Yes | Folder to delete. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
299+
| `recursive` | boolean | No | Also delete the folder’s nested folders and files. Without it, deleting a non-empty folder fails. |
300+
301+
#### Output
302+
303+
| Parameter | Type | Description |
304+
| --------- | ---- | ----------- |
305+
| `path` | string | The folder that was deleted. |
306+
| `deleted` | boolean | Always true when the operation succeeded. |
307+
| `deletedItems` | object | Counts of the folders and files deleted alongside it. |
308+
309+
### Restore File Folder
310+
311+
Restore a deleted workspace file folder and its contents from Recently deleted. Addressed by folder ID, because a deleted folder has no live path.
312+
313+
#### Input
314+
315+
| Parameter | Type | Required | Description |
316+
| --------- | ---- | -------- | ----------- |
317+
| `folderId` | string | Yes | ID of the deleted folder to restore. |
318+
319+
#### Output
320+
321+
| Parameter | Type | Description |
322+
| --------- | ---- | ----------- |
323+
| `folder` | object | The restored folder at its live path. |
324+
| `restoredItems` | object | Counts of the folders and files restored alongside it. |
325+
326+
### Move File
327+
328+
Move an existing workspace file into a folder. Moves the file itself; use Move File Folder to relocate a whole folder.
329+
330+
#### Input
331+
332+
| Parameter | Type | Required | Description |
333+
| --------- | ---- | -------- | ----------- |
334+
| `fileId` | string | Yes | Canonical workspace file ID of the file to move. |
335+
| `folderPath` | string | No | Destination folder. Omit to move the file to the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
336+
337+
#### Output
338+
339+
| Parameter | Type | Description |
340+
| --------- | ---- | ----------- |
341+
| `fileId` | string | The file that was moved. |
342+
| `folderPath` | string | The folder the file now lives in. |
343+
226344

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,10 @@ export const GET = defineV2JsonRoute({
3232
sortOrder: query.sortOrder,
3333
}),
3434
useCase: listWorkspaceFileFoldersOperation,
35-
present: ({ folders }) => ({ data: folders.map(toV2Folder), nextCursor: null }),
35+
present: ({ folders }) => ({
36+
data: folders.map(toWorkspaceFileFolderPathView),
37+
nextCursor: null,
38+
}),
3639
})
3740

3841
export const POST = defineV2JsonRoute({
@@ -43,7 +46,7 @@ export const POST = defineV2JsonRoute({
4346
errorPolicy: v2FileErrorPolicies.default,
4447
mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }),
4548
useCase: createWorkspaceFileFolderOperation,
46-
present: ({ folder }) => ({ data: toV2Folder(folder) }),
49+
present: ({ folder }) => ({ data: toWorkspaceFileFolderPathView(folder) }),
4750
})
4851

4952
export const PATCH = defineV2JsonRoute({
@@ -58,7 +61,7 @@ export const PATCH = defineV2JsonRoute({
5861
destinationPath: body.destinationPath,
5962
}),
6063
useCase: updateWorkspaceFileFolderOperation,
61-
present: ({ folder }) => ({ data: toV2Folder(folder) }),
64+
present: ({ folder }) => ({ data: toWorkspaceFileFolderPathView(folder) }),
6265
})
6366

6467
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)