Skip to content

Commit 1226ccd

Browse files
committed
feat(files): folder operations for the File block, and folders as scope
Adds folder operations to file_v5 and 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. Entries are a discriminated union on kind, and the listing is capped with a truncated flag rather than 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; 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 the picker, but the folder does travel when the advanced entry supplies a name rather than an id, because a name is only unique inside a folder. Write gains a folder destination, placed above File Name because it names where before it names what. Two things are worth a reviewer's attention. Path handling. 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 "/". folderPathSegments picks the parser by the leading slash the canonical form always carries, and resolveFolderIdsForPaths, isFileInFolderScope and selectDirectoryEntries are pure and tested against exactly that case. Where a folder is known, the code resolves by id and never builds a path-shaped reference at all. Delete Folder's recursive flag is a guard, not 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. This is deliberately the inverse of the read family's Include Subfolders, which is a scope and defaults on. Five file operations widen from copilot-only delegation to admit the executor, each justified by a tool in this change: files.list and files.folders.list for file_list, and files.folders.update / delete / restore for their tools. Principal kinds are unchanged and copilot keeps every operation it had.
1 parent 65a58a8 commit 1226ccd

46 files changed

Lines changed: 4114 additions & 153 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/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/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx

Lines changed: 93 additions & 6 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,17 +725,17 @@ 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
}
649736
}),
650737
],
651-
[workspaceFiles, acceptedTypes, cloudUploadBlocked]
738+
[scopedWorkspaceFiles, acceptedTypes, cloudUploadBlocked]
652739
)
653740

654741
// Find the selected file's workspace ID for highlighting in single file mode

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export { ResponseFormat } from './response'
2323
export { ScheduleInfo } from './schedule-info'
2424
export { SelectorInput, type SelectorOverrides } from './selector-input'
2525
export { ShortInput } from './short-input'
26+
export { SimFolderTreeSelector } from './sim-folder-tree-selector/sim-folder-tree-selector'
2627
export { SkillInput } from './skill-input'
2728
export { SliderInput } from './slider-input'
2829
export { SortBuilder } from './sort-builder'
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)