Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions apps/docs/content/docs/integrations/file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Read workspace file objects from selected files or canonical workspace file IDs.
| --------- | ---- | -------- | ----------- |
| `fileId` | string | No | Canonical workspace file ID, or an array of canonical workspace file IDs. |
| `fileInput` | file | No | Selected workspace file object. |
| `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. |
Comment thread
mzxchandra marked this conversation as resolved.
| `includeSubfolders` | boolean | No | Whether nested folders are read too. Defaults to true; set false to take only the folders’ direct files. |

#### Output

Expand All @@ -61,6 +63,8 @@ Extract the text content of one or more workspace files from selected file objec
| --------- | ---- | -------- | ----------- |
| `fileId` | string | No | Canonical workspace file ID, or an array of canonical workspace file IDs. |
| `fileInput` | file | No | Selected workspace file object, or an array of file objects. |
| `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. |
| `includeSubfolders` | boolean | No | Whether nested folders are read too. Defaults to true; set false to take only the folders’ direct files. |

#### Output

Expand Down Expand Up @@ -125,6 +129,7 @@ Create a new workspace file, either from text content or from an existing file.
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `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. |
| `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 "/". |
| `content` | string | No | The text content to write to the file. Provide exactly one of content or fileInput. |
| `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. |
| `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. |
Expand All @@ -148,6 +153,7 @@ Append content to an existing workspace file. The file must already exist. Conte
| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileName` | string | Yes | Name of an existing workspace file to append to. |
| `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 "/". |
| `content` | string | Yes | The text content to append to the file. |

#### Output
Expand All @@ -169,6 +175,8 @@ Compress one or more workspace files into a single .zip archive stored in the wo
| --------- | ---- | -------- | ----------- |
| `fileId` | string | No | Canonical workspace file ID, or an array of canonical workspace file IDs. |
| `fileInput` | file | No | Selected workspace file object, or an array of file objects. |
| `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. |
| `includeSubfolders` | boolean | No | Whether nested folders are read too. Defaults to true; set false to take only the folders’ direct files. |
| `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". |

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

### List Files and Folders

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.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `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 "/". |
| `recursive` | boolean | No | List everything beneath the path rather than only its direct children. Each entry carries its depth below the listed folder. |
| `depth` | number | No | Deepest level to include when recursive, counted from the listed folder. 1 is direct children. |
| `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. |
| `limit` | number | No | Most entries to return, 200 by default. A listing cut short comes back with truncated set. |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `path` | string | The folder that was listed. |
| `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. |
| `truncated` | boolean | True when the limit cut the listing short, so more entries exist. |

### Create File Folder

Create a workspace file folder at a path. Parent folders are created as needed. Fails if a folder already exists at the path.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | Yes | Path of the folder to create. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `folder` | object | The created folder, with its name, canonical path, parent path, and timestamps. |

### Move File Folder

Move or rename a workspace file folder by giving its full destination path. Everything inside the folder moves with it.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | Yes | Folder to move. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
| `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 "/". |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `folder` | object | The folder at its new path. |
| `previousPath` | string | The path the folder had before the move. |

### Delete File Folder

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.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | Yes | Folder to delete. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
| `recursive` | boolean | No | Also delete the folder’s nested folders and files. Without it, deleting a non-empty folder fails. |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `path` | string | The folder that was deleted. |
| `deleted` | boolean | Always true when the operation succeeded. |
| `deletedItems` | object | Counts of the folders and files deleted alongside it. |

### Restore File Folder

Restore a deleted workspace file folder and its contents from Recently deleted. Addressed by folder ID, because a deleted folder has no live path.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `folderId` | string | Yes | ID of the deleted folder to restore. |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `folder` | object | The restored folder at its live path. |
| `restoredItems` | object | Counts of the folders and files restored alongside it. |

### Move File

Move an existing workspace file into a folder. Moves the file itself; use Move File Folder to relocate a whole folder.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | Canonical workspace file ID of the file to move. |
| `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 "/". |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `fileId` | string | The file that was moved. |
| `folderPath` | string | The folder the file now lives in. |


Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { isApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { fileDeleteContract } from '@/lib/api/contracts/storage-transfer'
import { getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils'
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
import { isFileInFolderScope } from '@/lib/workspace-files/folder-path-selection'
import { formatDisplayText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/formatted-text'
import { getWorkflowSearchLabelHighlight } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-search-highlight'
import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value'
Expand Down Expand Up @@ -45,6 +47,11 @@ interface FileUploadProps {
isPreview?: boolean
previewValue?: any | null
disabled?: boolean
/**
* A sibling folder field that narrows what this picker offers, and the switch
* saying whether that scope descends. See `SubBlockConfig.folderScope`.
*/
folderScope?: { fieldId: string; recursiveFieldId?: string }
/**
* Controlled value. When `onValueChange` is provided the component reads from
* this prop and writes through `onValueChange` instead of the subblock store,
Expand All @@ -55,12 +62,56 @@ interface FileUploadProps {
onValueChange?: (value: UploadedFile | UploadedFile[] | null) => void
}

/**
* Label for a workspace file, prefixed with its folder so two files sharing a
* name are distinguishable.
*
* The stored folder path escapes a slash inside a folder name, so it is decoded
* into segments rather than split — otherwise a folder named `Q3/Q4` reads as
* two levels.
*/
function workspaceFileOptionLabel(file: { name: string; folderPath?: string | null }): string {
if (!file.folderPath) return file.name
try {
return `${parseWorkspaceFileFolderDisplayPath(file.folderPath).join(' / ')} / ${file.name}`
} catch {
return file.name
}
}

/** Groups files by folder, then by name, so the list reads folder by folder. */
function byFolderThenName(
a: { name: string; folderPath?: string | null },
b: { name: string; folderPath?: string | null }
): number {
const folderOrder = (a.folderPath ?? '').localeCompare(b.folderPath ?? '')
return folderOrder !== 0 ? folderOrder : a.name.localeCompare(b.name)
}

export interface UploadedFile {
name: string
path: string
key?: string
size: number
type: string
/**
* Canonical workspace file id, present when the file was chosen from the
* workspace rather than uploaded in place.
*
* Carrying it is what makes a chosen file resolvable to exactly one row. A
* name alone is ambiguous the moment the same one exists in two folders, and
* the reference resolver then falls back to the oldest match anywhere in the
* workspace — so dropping the id here turned a precise choice into a guess.
*
* Optional, because an upload has no workspace id until it lands.
*/
id?: string
/**
* Folder of a chosen workspace file, as the stored backslash-escaped display
* path (`a\/b` is one folder named `a/b`). Decode it with
* `parseWorkspaceFileFolderDisplayPath` — never by splitting on `/`.
*/
folderPath?: string
}

interface SingleFileSelectorProps {
Expand Down Expand Up @@ -180,6 +231,7 @@ export function FileUpload({
isPreview = false,
previewValue,
disabled = false,
folderScope,
value: controlledValue,
onValueChange,
}: FileUploadProps) {
Expand Down Expand Up @@ -274,7 +326,40 @@ export function FileUpload({
})
}

const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => {
/*
* A sibling folder field narrows what this picker offers. Choosing a folder
* means the run only touches that folder, so listing files from anywhere else
* would let a selection be built that the operation then ignores — the picker
* has to describe the same set the run will read.
*
* Falling back to this control's own id keeps the hook call unconditional for
* a picker with no folder scope; its own value is never a folder path, so the
* scope reads as absent.
*/
const [folderScopeValue] = useSubBlockValue<unknown>(blockId, folderScope?.fieldId ?? subBlockId)
const [folderScopeRecursive] = useSubBlockValue<unknown>(
blockId,
folderScope?.recursiveFieldId ?? subBlockId
)
const folderScopePath =
folderScope && typeof folderScopeValue === 'string' ? folderScopeValue.trim() : ''
const folderScopeIncludesSubfolders =
!folderScope?.recursiveFieldId ||
folderScopeRecursive === undefined ||
folderScopeRecursive === null ||
folderScopeRecursive === '' ||
folderScopeRecursive === true ||
folderScopeRecursive === 'true'

const scopedWorkspaceFiles = folderScopePath
? workspaceFiles.filter((workspaceFile) =>
isFileInFolderScope(workspaceFile.folderPath, folderScopePath, {
includeSubfolders: folderScopeIncludesSubfolders,
})
)
: workspaceFiles

const availableWorkspaceFiles = scopedWorkspaceFiles.filter((workspaceFile) => {
const existingFiles = Array.isArray(value) ? value : value ? [value] : []

const isAlreadySelected = existingFiles.some(
Expand Down Expand Up @@ -486,6 +571,8 @@ export function FileUpload({
key: selectedFile.key,
size: selectedFile.size,
type: selectedFile.type,
id: selectedFile.id,
folderPath: selectedFile.folderPath ?? undefined,
}

if (multiple) {
Expand Down Expand Up @@ -620,11 +707,11 @@ export function FileUpload({
const comboboxOptions = useMemo(
() => [
{ label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked },
...availableWorkspaceFiles.map((file) => {
...[...availableWorkspaceFiles].sort(byFolderThenName).map((file) => {
const isAccepted =
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
return {
label: file.name,
label: workspaceFileOptionLabel(file),
value: file.id,
// When cloud is required, local workspace files are also unpublishable.
disabled: !isAccepted || cloudUploadBlocked,
Expand All @@ -638,17 +725,17 @@ export function FileUpload({
const singleFileOptions = useMemo(
() => [
{ label: 'Upload New File', value: '__upload_new__', disabled: cloudUploadBlocked },
...workspaceFiles.map((file) => {
...[...scopedWorkspaceFiles].sort(byFolderThenName).map((file) => {
Comment thread
mzxchandra marked this conversation as resolved.
const isAccepted =
!acceptedTypes || acceptedTypes === '*' || isFileTypeAccepted(file.type, acceptedTypes)
return {
label: file.name,
label: workspaceFileOptionLabel(file),
value: file.id,
disabled: !isAccepted || cloudUploadBlocked,
}
}),
],
[workspaceFiles, acceptedTypes, cloudUploadBlocked]
[scopedWorkspaceFiles, acceptedTypes, cloudUploadBlocked]
)

// Find the selected file's workspace ID for highlighting in single file mode
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export { ResponseFormat } from './response'
export { ScheduleInfo } from './schedule-info'
export { SelectorInput, type SelectorOverrides } from './selector-input'
export { ShortInput } from './short-input'
export { SimFolderTreeSelector } from './sim-folder-tree-selector/sim-folder-tree-selector'
export { SkillInput } from './skill-input'
export { SliderInput } from './slider-input'
export { SortBuilder } from './sort-builder'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Reads a stored folder value into a canonical percent-encoded path.
*
* The picker stores a plain string, but the manual half of the pair is a text
* field, so a reference like `<block.folderPath>` resolves to a string before it
* gets here. A JSON array is tolerated because an earlier revision of this
* control stored one, and reading only its first entry is closer to the intent
* than discarding the value.
*/
export function readFolderPath(value: unknown): string {
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return ''
if (trimmed.startsWith('[')) {
try {
return readFolderPath(JSON.parse(trimmed))
} catch {
return trimmed
}
}
return trimmed
}
if (Array.isArray(value)) {
const first = value.find((entry) => typeof entry === 'string' && entry.length > 0)
return typeof first === 'string' ? first : ''
}
return ''
}
Loading
Loading