diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index 3d04694e7b8..be9e2330c84 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -108,6 +108,8 @@ Also available as `sim files folders ls`. | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--recursive ` | No | Whether parentPath includes every descendant instead of direct children only. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. | +| `--depth ` | No | Deepest level below parentPath to include when recursive is true. | @@ -172,6 +174,32 @@ sim files delete [options] +## Apply one exact or anchor-based edit to a text file + +```bash +sim files edit [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--edit ` | Yes | One edit object: {"mode":"search_replace","search":"old","content":"new","replaceAll":false}, {"mode":"replace_between","beforeAnchor":"start line","afterAnchor":"end line","content":"new"}, {"mode":"insert_after","anchor":"line","content":"new"}, or {"mode":"delete_between","startAnchor":"first line deleted","endAnchor":"ending line kept"}. Anchored modes also accept occurrence starting at 1 (JSON, or @path / @- to read a file or stdin). | + + + ## Show file metadata and sharing status ```bash @@ -310,6 +338,8 @@ sim files read [options] | Option | Required | Description | | --- | --- | --- | | `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | +| `--offset ` | No | First line to return, 1-based. Absent starts at the first line. | +| `--limit ` | No | How many lines to return from `offset`. Absent reads to the end. | @@ -355,6 +385,27 @@ sim files restore +## Search file content + +```bash +sim files search [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--query ` | Yes | Regular expression, or exact text when `mode` is `exact`. | +| `--mode ` | No | How `query` is read. Accepted values: `exact`, `regex`. | +| `--max-results ` | No | Maximum matching lines to return. | +| `--folder ` | No | Folders to search, by path as shown in the app; omit to search the whole workspace (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--include-subfolders` | No | Whether each folder scope includes nested folders; on by default. | +| `--no-include-subfolders` | No | Send --include-subfolders as false. | + + + ## Unzip an archive into a new folder beside it ```bash diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 2aacf33181f..b6c6cc0b2e5 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -788,6 +788,8 @@ Also available as `sim files folders ls`. | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--recursive ` | No | Whether parentPath includes every descendant instead of direct children only. Accepted values: `true`, `1`, `yes`, `on`, `y`, `enabled`, `false`, `0`, `no`, `off`, `n`, `disabled`. | +| `--depth ` | No | Deepest level below parentPath to include when recursive is true. | @@ -858,6 +860,34 @@ sim files delete [options] +### sim files edit + +Apply one exact or anchor-based edit to a text file + +```bash +sim files edit [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | File identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--edit ` | Yes | One edit object: {"mode":"search_replace","search":"old","content":"new","replaceAll":false}, {"mode":"replace_between","beforeAnchor":"start line","afterAnchor":"end line","content":"new"}, {"mode":"insert_after","anchor":"line","content":"new"}, or {"mode":"delete_between","startAnchor":"first line deleted","endAnchor":"ending line kept"}. Anchored modes also accept occurrence starting at 1 (JSON, or @path / @- to read a file or stdin). | + + + ### sim files describe Show file metadata and sharing status @@ -1006,6 +1036,8 @@ sim files read [options] | Option | Required | Description | | --- | --- | --- | | `--max-bytes ` | No | Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit. | +| `--offset ` | No | First line to return, 1-based. Absent starts at the first line. | +| `--limit ` | No | How many lines to return from `offset`. Absent reads to the end. | @@ -1055,6 +1087,29 @@ sim files restore +### sim files search + +Search File Content + +```bash +sim files search [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--query ` | Yes | Regular expression, or exact text when `mode` is `exact`. | +| `--mode ` | No | How `query` is read. Accepted values: `exact`, `regex`. | +| `--max-results ` | No | Maximum matching lines to return. | +| `--folder ` | No | Folders to search, by path as shown in the app; omit to search the whole workspace (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--include-subfolders` | No | Whether each folder scope includes nested folders; on by default. | +| `--no-include-subfolders` | No | Send --include-subfolders as false. | + + + ### sim files unzip Unzip an archive into a new folder beside it diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index 23cbb7cfdb5..a916d2faf4f 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -15,20 +15,20 @@ The File block is a built-in Sim block for working with files stored in the work With the File block, you can: -- **Read and extract content**: Load workspace file objects and extract their text content -- **Search workspace content**: Match a regular expression, or an exact piece of text, against the indexed lines of active workspace files with bounded line-level results +- **Read and extract content**: Load workspace file objects and extract their text content from selected files or one or more folders +- **Search workspace content**: Match a regular expression, or an exact piece of text, across the workspace or selected folder scopes with bounded line-level results - **Fetch from URLs**: Retrieve and parse files from external URLs with custom headers - **Write and append**: Create new workspace files or append content to existing ones - **Compress and decompress**: Bundle files into a .zip archive or extract an archive into the workspace - **Manage sharing**: Enable or disable a public share link for a file, with public, password, email, or SSO access modes -In Sim, the File block allows your agents to search, read, and extract text from workspace files, fetch and parse files from URLs, write or append content to files, bundle files into or out of .zip archives, and control public sharing access for a file—all programmatically as steps in a workflow. This makes it possible to explore workspace content, move file content into and out of a workflow, package outputs for download or transfer, and expose files to external users through a managed share link. +In Sim, the File block allows your agents to search, read, and extract text from workspace files, fetch and parse files from URLs, write or append content to files, bundle files into or out of .zip archives, and control public sharing access for a file—all programmatically as steps in a workflow. Folder selection is optional and supports multiple folders; when no folder is selected, search spans the workspace and file pickers are unscoped. Selected folders are expanded when the workflow runs, so newly added files are included automatically. This makes it possible to explore workspace content, move file content into and out of a workflow, package outputs for download or transfer, and expose files to external users through a managed share link. {/* MANUAL-CONTENT-END */} ## Usage Instructions -Read workspace file objects, search indexed text across all active workspace files, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file. +Read workspace file objects, search indexed text across the workspace or selected folder scopes, extract the text content of files, fetch and parse files from URLs with optional headers, write new workspace files, append content to existing files, compress files into a .zip archive, extract a .zip archive into the workspace, or manage the public share link for a file. @@ -36,7 +36,7 @@ Read workspace file objects, search indexed text across all active workspace fil ### File Read -Read workspace file objects from selected files or canonical workspace file IDs. +Read workspace file objects from selected files, canonical workspace file IDs, or one or more workspace folders. #### Input @@ -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. | +| `includeSubfolders` | boolean | No | Whether nested folders are read too. Defaults to true; set false to take only the folders’ direct files. | #### Output @@ -53,7 +55,7 @@ Read workspace file objects from selected files or canonical workspace file IDs. ### File Get Content -Extract the text content of one or more workspace files from selected file objects or canonical workspace file IDs. +Extract the text content of workspace files selected directly, identified by canonical file ID, or collected from one or more workspace folders. #### Input @@ -61,16 +63,21 @@ 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. | +| `offset` | number | No | First line to return, 1-based. Applied to each selected file separately, so a multi-file selection returns the same window of each. Absent starts at the first line. | +| `limit` | number | No | How many lines to return from the offset. Absent reads to the end. Use this to read part of a long file instead of all of it; the response reports the total line count alongside the window. | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | | `contents` | array | Array of file text contents, one entry per file in input order | +| `lineRanges` | array | Present when a line range was requested: one entry per file, in the same order, with offset, lineCount, and totalLines | ### File Search -Search the indexed text of active workspace files for lines matching a regular expression, and return each matching line once with its file ID and line number. Coverage is what the index currently holds, so check "complete" and "indexStatus" before concluding that something is absent. +Search the indexed text of active workspace files for lines matching a query, and return each matching line once with its file ID and line number. By default the query is a regular expression; in exact mode it is matched verbatim and metacharacters are literal. Coverage is what the index currently holds. A term that is not found is only authoritative when "complete" is true AND "indexStatus" reports no skipped or partial files; otherwise it is unknown rather than absent, so re-check before creating something on the assumption it is missing. Narrow the search with folderPaths to confine it to one or more folder trees, which also narrows "indexStatus" to those trees. #### Input @@ -79,6 +86,8 @@ Search the indexed text of active workspace files for lines matching a regular e | `query` | string | Yes | A regular expression matched against each line, 3-512 characters. Supports "." "*" "+" "?" "\{n,m\}" and their lazy forms, character classes such as "\[a-z\]" and "\[^0-9\]", the classes \d \w \s and \D \W \S, alternation "\|", groups "\(...\)" and "\(?:...\)", the anchors "^" and "$", and the word boundary \b. Lookahead, lookbehind, backreferences, named groups, inline flags such as "\(?i\)", \p\{...\} and POSIX "\[\[:alpha:\]\]" classes are not supported, and a pattern cannot span a line break. The pattern must contain at least 3 consecutive literal characters that every match will include — write "error \d+" rather than "\w+ \d+". Escape any metacharacter you mean literally. Matching is case-insensitive until the pattern contains an uppercase letter you are searching for; uppercase inside an escape or a character class, such as \D or \[A-Z\], does not make it case-sensitive. When the workflow builder sets Match to exact instead, the query is matched verbatim and no metacharacter needs escaping. | | `mode` | string | No | How the query is read, chosen by the workflow builder: "regex" \(default\) as a regular expression, or "exact" as verbatim text. | | `maxResults` | number | No | Hard result cap configured by the workflow builder \(1-200, default 50\). | +| `folderPaths` | array | No | Folders the search is confined to, as canonical percent-encoded paths, e.g. \["/memory/user-a"\]. Absent searches the whole workspace. Scoping also narrows the reported index coverage, so "complete" describes the folders searched. | +| `includeSubfolders` | boolean | No | Whether the scope descends into nested folders. Defaults to true; set false to search only the folders’ direct files. | #### Output @@ -125,6 +134,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. | @@ -148,6 +158,9 @@ 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 | Single folder in which to resolve the file name. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". Use folderPaths for multiple folders; do not provide both fields. | +| `folderPaths` | array | No | Folders to search for the named file. The name must resolve to exactly one file across the selected scopes. Do not provide folderPath as well. | +| `includeSubfolders` | boolean | No | Whether the folder scope includes nested folders. Defaults to true; set false to target only files directly in the folder. | | `content` | string | Yes | The text content to append to the file. | #### Output @@ -159,6 +172,38 @@ Append content to an existing workspace file. The file must already exist. Conte | `size` | number | File size in bytes | | `url` | string | URL to access the file | +### Apply File Edit + +Apply one precise edit to an existing text file without rewriting it. Use search_replace for verbatim replacement, optionally with replaceAll. Use replace_between, insert_after, or delete_between for complete trimmed-line anchors that stay stable when line numbers move. Folder scope can disambiguate a name or constrain a file ID. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `fileName` | string | Yes | Name or ID of the workspace file to edit. | +| `folderPath` | string | No | Single folder in which to resolve the file name or validate the file ID. Canonical folder path, percent-encoded, e.g. "/memory/user-a/people". The workspace root is "/". Use folderPaths for multiple folders; do not provide both fields. | +| `folderPaths` | array | No | Folders to search for the named file or validate the file ID against. The name must resolve to exactly one file across the selected scopes. Do not provide folderPath as well. | +| `includeSubfolders` | boolean | No | Whether selected folders are searched recursively. Defaults to true; false matches only their direct contents. | +| `mode` | string | Yes | Edit type: search_replace, replace_between, insert_after, or delete_between. | +| `search` | string | No | For search_replace, the exact text to replace, including whitespace and line breaks. It must be unique unless replaceAll is true. | +| `content` | string | No | Replacement or inserted text. Pass an empty string to delete a search match or clear the text between anchors. Omit only for delete_between. | +| `replaceAll` | boolean | No | For search_replace, replace every non-overlapping match. Defaults to false, which refuses an ambiguous match. | +| `beforeAnchor` | string | No | For replace_between, the complete line before the content to replace. Leading and trailing whitespace is ignored. | +| `afterAnchor` | string | No | For replace_between, the complete line after the content to replace. The anchor lines remain in the file. | +| `anchor` | string | No | For insert_after, the complete line after which content is inserted. | +| `startAnchor` | string | No | For delete_between, the complete first line to delete. The start anchor is removed. | +| `endAnchor` | string | No | For delete_between, the complete ending boundary line. The end anchor remains in the file. | +| `occurrence` | number | No | For anchored edits, which matching anchor occurrence to use, starting at 1. Defaults to 1. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | string | File ID | +| `name` | string | File name | +| `size` | number | File size in bytes | +| `lineCount` | number | Lines in the file after the edit | + ### File Compress Compress one or more workspace files into a single .zip archive stored in the workspace, for bundling files to download, transfer, or store. Preserves the workspace folder structure of the selected files. @@ -169,6 +214,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 @@ -223,4 +270,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. | + diff --git a/apps/docs/content/docs/platform/enterprise/forks.mdx b/apps/docs/content/docs/platform/enterprise/forks.mdx index 62c0c60574a..4301284fd9a 100644 --- a/apps/docs/content/docs/platform/enterprise/forks.mdx +++ b/apps/docs/content/docs/platform/enterprise/forks.mdx @@ -81,7 +81,7 @@ A **mapping** means: “this resource in the source is the same logical thing as Mappings are saved on the fork relationship. You can click **Save** without syncing. Sync always uses the saved mappings. -After you map or copy a parent resource (credential, knowledge base, table, …), **dependent fields** — Gmail labels, Slack channels, knowledge base documents, and similar — often need a fresh pick in the target. Required dependents block **Sync** until they are filled. +After you map or copy a parent resource (credential, knowledge base, table, …), **dependent fields** — Gmail labels, Slack channels, knowledge base documents, and similar — often need a fresh pick in the target. Required dependents block **Sync** until they are filled. Workspace file-folder scopes are mapped by canonical path; an empty folder selection creates no mapping requirement, and **Include Subfolders** does not block by itself. ### 5. Map, reconfigure, then Sync @@ -173,6 +173,7 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a | Undeployed workflows | Not copied | Not synced | | [Excluded workflows](#excluded-workflows) | Never | Never — not sent, not overwritten, not archived | | Files | Optional copy (default on) | Map or copy | +| File folders referenced by workflows | Mirrored with their ancestor folders, even when empty | Map by canonical path | | Tables | Optional copy (default on) | Map or copy | | Knowledge bases (+ documents) | Optional copy; uploaded documents come with the KB, [connector-synced ones do not](#connector-synced-documents-are-not-copied) | Map or copy; documents follow the KB | | Custom tools | Optional copy (default on) | Map or copy | @@ -205,10 +206,12 @@ Only **deployed** workflows move. Deploy is the commit; sync is the force push/p | | Behavior | |---|----------| -| **Fork** | Listed under **Copy resources** (default on). Copies land at the child’s **file root** (original file folders are not rebuilt). Deselect → file fields in workflows clear. | -| **Sync** | Map to a file that already exists on the target, or copy. Files used by the sync’s workflows default to selected. | +| **Fork** | Listed under **Copy resources** (default on). A copied file keeps its containing folder structure. Folder scopes referenced by copied workflows are mirrored with their ancestors even when they are empty; mirroring a scope does not implicitly copy every file inside it. Deselect a file → that file field clears. | +| **Sync** | Map to a file that already exists on the target, or copy. Files used by the sync’s workflows default to selected. Selected workspace file-folder scopes are mapped separately by canonical path. The same path is suggested automatically when it exists on both sides; otherwise create or choose the intended target folder before syncing. | -**Example:** A workflow attaches `brand-guide.pdf`. Fork with Files selected → the child has its own copy and the block still points at it. +**Example:** A workflow attaches `Brand/brand-guide.pdf` and searches an empty `/Incoming` folder. Fork with Files selected → the child gets its own file under `/Brand`, and `/Incoming` is also created so the search scope still points somewhere valid without copying unrelated files into it. + +Only concrete, active workspace folder selections participate in this mapping. Leaving **Folder** empty means “anywhere in the workspace” and never blocks sync. **Include Subfolders** only changes how a selected scope is expanded. Folder selectors backed by external providers are credential-scoped provider values, not workspace file-folder mappings. --- diff --git a/apps/docs/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index 2d78e782187..e424d596078 100644 --- a/apps/docs/lib/openapi-download.test.ts +++ b/apps/docs/lib/openapi-download.test.ts @@ -33,7 +33,7 @@ describe('OpenAPI download', () => { const tags = document.tags as Array<{ name: string }> expect(document.openapi).toBe('3.1.0') - expect(Object.keys(paths)).toHaveLength(129) + expect(Object.keys(paths)).toHaveLength(130) expect(tags.map((tag) => tag.name)).toEqual([ 'Workflows', 'Workflow Runs', diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 651d2d3fe41..2ae1f1de435 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -767,6 +767,30 @@ "minimum": 1, "maximum": 26214400 } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "First line to return, 1-based. Absent starts at the first line.", + "schema": { + "description": "First line to return, 1-based. Absent starts at the first line.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "How many lines to return from `offset`. Absent reads to the end.", + "schema": { + "description": "How many lines to return from `offset`. Absent reads to the end.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } } ], "responses": { @@ -1923,6 +1947,94 @@ } }, "/api/v2/files/{fileId}/content": { + "patch": { + "operationId": "editFileContent", + "summary": "Edit File Content", + "description": "Change part of a text file in place, leaving the rest untouched. `PUT` on this path replaces the whole file; this is the partial counterpart. `search_replace` matches exact text and requires one match unless `replaceAll` is true. `replace_between`, `insert_after`, and `delete_between` match complete lines after trimming surrounding whitespace, so edits remain stable when unrelated changes move the target to another line. Anchored replacement preserves both boundary lines; insertion preserves its anchor; deletion removes the start anchor and preserves the end anchor. Use `occurrence` when an anchor line repeats. Only files whose stored bytes are UTF-8 text can be edited: a PDF or DOCX answers `400`. A concurrent write answers `409`, and retrying means re-reading first.", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and the change to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditFileContentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The edited file and its new line count.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2EditedFileResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, "put": { "operationId": "updateFileContent", "summary": "Replace File Content", @@ -2006,6 +2118,146 @@ } } }, + "/api/v2/files/search": { + "get": { + "operationId": "searchFileContent", + "summary": "Search File Content", + "description": "Search the indexed text of active workspace files and return each matching line with its file id and line number. `folderPaths` confines the search to one or more folder trees, which also narrows the reported coverage, so `complete` and `indexStatus` describe the folders searched rather than the whole workspace. Coverage matters: the index is built asynchronously, so when `complete` is `false` a term that was not found is **unknown rather than absent**, and acting on the absence risks creating a duplicate of something already stored. `truncated` separately reports that more matches exist beyond `maxResults`.", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace to search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace to search." + } + }, + { + "name": "query", + "in": "query", + "required": true, + "description": "Regular expression, or exact text when `mode` is `exact`.", + "schema": { + "type": "string", + "description": "Regular expression, or exact text when `mode` is `exact`.", + "minLength": 3, + "maxLength": 512 + } + }, + { + "name": "mode", + "in": "query", + "required": false, + "description": "How `query` is read.", + "schema": { + "default": "regex", + "description": "How `query` is read.", + "type": "string", + "enum": ["exact", "regex"] + } + }, + { + "name": "maxResults", + "in": "query", + "required": false, + "description": "Maximum matching lines to return.", + "schema": { + "default": 50, + "description": "Maximum matching lines to return.", + "type": "integer", + "minimum": 1, + "maximum": 200 + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", + "schema": { + "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", + "type": "string" + } + }, + { + "name": "includeSubfolders", + "in": "query", + "required": false, + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Matching lines and the index coverage they were drawn from.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileSearchResultsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/files/bulk-delete": { "post": { "operationId": "bulkDeleteFiles", @@ -2151,6 +2403,42 @@ "type": "string", "enum": ["active", "archived"] } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Whether parentPath includes every descendant instead of direct children only.", + "schema": { + "description": "Whether parentPath includes every descendant instead of direct children only.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "depth", + "in": "query", + "required": false, + "description": "Deepest level below parentPath to include when recursive is true.", + "schema": { + "description": "Deepest level below parentPath to include when recursive is true.", + "type": "integer", + "minimum": 1, + "maximum": 64 + } } ], "responses": { @@ -2677,7 +2965,7 @@ "example": { "error": { "code": "CONFLICT", - "message": "File already exists" + "message": "The request conflicts with the current resource state" } } } @@ -2715,6 +3003,22 @@ } } }, + "Locked": { + "description": "The resource is temporarily locked or unavailable; retry the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "The file or its search index is temporarily locked; retry the request" + } + } + } + } + }, "RateLimited": { "description": "The caller exceeded the request rate limit.", "headers": { @@ -3395,6 +3699,36 @@ "minimum": 0, "maximum": 9007199254740991, "description": "Source bytes read from storage before extraction." + }, + "lineRange": { + "description": "Present when `offset` or `limit` narrowed the response. `totalLines` is what separates a file that ended from a window that stopped early.", + "type": "object", + "properties": { + "offset": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "First line returned, 1-based." + }, + "lineCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lines returned." + }, + "totalLines": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lines the whole file holds." + }, + "totalLinesExact": { + "type": "boolean", + "description": "False when text extraction was truncated, so `totalLines` counts only the extracted prefix and is not the end of the file." + } + }, + "required": ["offset", "lineCount", "totalLines", "totalLinesExact"], + "additionalProperties": false } }, "required": [ @@ -4198,6 +4532,340 @@ } ] }, + "V2EditedFile": { + "type": "object", + "properties": { + "file": { + "description": "The file after the edit.", + "$ref": "#/components/schemas/V2File" + }, + "lineCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lines the file holds after the edit." + } + }, + "required": ["file", "lineCount"], + "additionalProperties": false, + "title": "Edited file", + "description": "A workspace file after an in-place content edit." + }, + "V2EditedFileResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2EditedFile" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Edited file response", + "description": "A workspace file after an in-place content edit.", + "examples": [ + { + "data": { + "file": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/example/data.csv", + "folderPath": "/Engineering", + "uploadedByEmail": "jane@example.com", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + }, + "lineCount": 5 + } + } + ] + }, + "EditFileContentRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + }, + "edit": { + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "search_replace", + "description": "Replace exact text." + }, + "search": { + "type": "string", + "minLength": 1, + "description": "Exact text to replace, matched verbatim. It must appear once unless replaceAll is true." + }, + "content": { + "type": "string", + "description": "Text to put in its place. An empty string deletes the matched text." + }, + "replaceAll": { + "description": "Replace every non-overlapping match. Defaults to false.", + "type": "boolean" + } + }, + "required": ["mode", "search", "content"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "replace_between", + "description": "Replace content between two complete-line anchors." + }, + "beforeAnchor": { + "type": "string", + "minLength": 1, + "description": "Boundary line before the replaced content. This line remains." + }, + "afterAnchor": { + "type": "string", + "minLength": 1, + "description": "Boundary line after the replaced content. This line remains." + }, + "content": { + "type": "string", + "description": "Replacement text. An empty string clears the interior." + }, + "occurrence": { + "description": "Matching anchor occurrence, starting at 1. Defaults to 1.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": ["mode", "beforeAnchor", "afterAnchor", "content"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "insert_after", + "description": "Insert content after a complete-line anchor." + }, + "anchor": { + "type": "string", + "minLength": 1, + "description": "Complete line after which content is inserted." + }, + "content": { + "type": "string", + "minLength": 1, + "description": "Text to insert." + }, + "occurrence": { + "description": "Matching anchor occurrence, starting at 1. Defaults to 1.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": ["mode", "anchor", "content"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "delete_between", + "description": "Delete from one complete-line anchor to another." + }, + "startAnchor": { + "type": "string", + "minLength": 1, + "description": "First line to delete. This start anchor is removed." + }, + "endAnchor": { + "type": "string", + "minLength": 1, + "description": "Ending boundary line. This end anchor remains." + }, + "occurrence": { + "description": "Matching anchor occurrence, starting at 1. Defaults to 1.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": ["mode", "startAnchor", "endAnchor"], + "additionalProperties": false + } + ], + "description": "One exact or anchor-based edit: search_replace, replace_between, insert_after, or delete_between." + } + }, + "required": ["workspaceId", "edit"], + "additionalProperties": false, + "title": "Edit file content request", + "description": "Workspace scope and the change to apply.", + "examples": [ + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "edit": { + "mode": "search_replace", + "search": "- based in NYC", + "content": "- based in SF" + } + }, + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "edit": { + "mode": "insert_after", + "anchor": "## Preferences", + "content": "- prefers async updates" + } + } + ] + }, + "V2FileSearchResults": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File the line belongs to." + }, + "lineNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "1-based line the match sits on." + }, + "text": { + "type": "string", + "description": "The matching line." + } + }, + "required": ["fileId", "lineNumber", "text"], + "additionalProperties": false + }, + "description": "Matching lines, one entry per line." + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of results returned." + }, + "truncated": { + "type": "boolean", + "description": "True when more matches exist beyond `maxResults`." + }, + "complete": { + "type": "boolean", + "description": "True when no file in the searched scope is still pending or failed indexing. It does NOT cover `skippedFiles` (never indexed, such as binaries) or `partialFiles` (indexed only in part), so a missing match is authoritative only when all three are clear. Treat any of them as nonzero meaning unknown rather than absent." + }, + "indexStatus": { + "type": "object", + "properties": { + "readyFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files whose current revision is indexed and searchable." + }, + "pendingFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files not yet indexed at their current revision. Their content was not searched." + }, + "failedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files whose indexing failed. Their content was not searched." + }, + "skippedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files deliberately not indexed, such as binaries and files above the size ceiling." + }, + "partialFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files indexed only in part, so matches beyond the indexed portion are not found." + } + }, + "required": [ + "readyFiles", + "pendingFiles", + "failedFiles", + "skippedFiles", + "partialFiles" + ], + "additionalProperties": false, + "description": "Index coverage across the searched scope." + } + }, + "required": ["results", "count", "truncated", "complete", "indexStatus"], + "additionalProperties": false, + "title": "File search results", + "description": "Matching lines from indexed workspace file content, with index coverage." + }, + "V2FileSearchResultsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileSearchResults" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "File search results response", + "description": "Matching lines from indexed workspace file content.", + "examples": [ + { + "data": { + "results": [ + { + "fileId": "wf_V1StGXR8z5jdHi6BmyT91", + "lineNumber": 4, + "text": "- based in NYC" + } + ], + "count": 1, + "truncated": false, + "complete": true, + "indexStatus": { + "readyFiles": 12, + "pendingFiles": 0, + "failedFiles": 0, + "skippedFiles": 0, + "partialFiles": 0 + } + } + } + ] + }, "UpdateFileContentRequest": { "type": "object", "properties": { diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index fb9688e5f6c..bb44fceaef5 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -4801,7 +4801,7 @@ } }, "Locked": { - "description": "The resource is locked and cannot be modified.", + "description": "The resource is temporarily locked or unavailable; retry the request.", "content": { "application/json": { "schema": { diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 98b2910e175..fa265533ea7 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -3796,7 +3796,7 @@ } }, "Locked": { - "description": "The resource is locked and cannot be modified.", + "description": "The resource is temporarily locked or unavailable; retry the request.", "content": { "application/json": { "schema": { diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 3c159cb4201..f829f3a44d6 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -14,6 +14,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ admit: vi.fn(), + editContent: vi.fn(), updateContent: vi.fn(), getUserEmailsByIds: vi.fn(), })) @@ -30,6 +31,13 @@ vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => }, })) +vi.mock('@/lib/workspace-files/application/edit-workspace-file-content', () => ({ + editWorkspaceFileContent: { + operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.editContent, + }, +})) + vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) @@ -40,7 +48,7 @@ vi.mock('@/lib/users/queries', () => ({ import { NoWorkspaceAccessError } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' -import { PUT } from '@/app/api/v2/files/[fileId]/content/route' +import { PATCH, PUT } from '@/app/api/v2/files/[fileId]/content/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' @@ -81,6 +89,19 @@ const callPut = (body: unknown, contentLength?: number) => { params: Promise.resolve({ fileId: FILE_ID }) } ) +const callPatch = (body: unknown, contentLength?: number) => + PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + ...(contentLength === undefined ? {} : { 'Content-Length': String(contentLength) }), + }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }), + { params: Promise.resolve({ fileId: FILE_ID }) } + ) + describe('PUT /api/v2/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() @@ -88,6 +109,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) mocks.admit.mockResolvedValue(undefined) + mocks.editContent.mockResolvedValue({ file: record, lineCount: 1 }) mocks.updateContent.mockResolvedValue({ file: record }) mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) @@ -130,6 +152,23 @@ describe('PUT /api/v2/files/[fileId]/content', () => { expect(mocks.updateContent).not.toHaveBeenCalled() }) + it('applies the same oversized-body admission limit to partial edits', async () => { + const response = await callPatch( + { + workspaceId: WORKSPACE_ID, + edit: { mode: 'search_replace', search: 'old', content: 'new' }, + }, + 70 * 1024 * 1024 + 1 + ) + + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ + error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, + }) + expect(mocks.admit).toHaveBeenCalled() + expect(mocks.editContent).not.toHaveBeenCalled() + }) + it('replaces content through the shared use case and returns the v2 projection', async () => { const request = new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { method: 'PUT', diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index c2a64201d23..673e0a3d3ed 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,6 +1,10 @@ -import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' +import { + v2EditFileContentContract, + v2UpdateFileContentContract, +} from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { editWorkspaceFileContent } from '@/lib/workspace-files/application/edit-workspace-file-content' import { fileOperations } from '@/lib/workspace-files/application/operations' import { admitUpdateWorkspaceFileContent, @@ -36,3 +40,37 @@ export const PUT = defineV2JsonRoute({ useCase: updateWorkspaceFileContent, present: async ({ file }) => ({ data: await toV2File(file) }), }) + +/** + * PATCH /api/v2/files/[fileId]/content — Change part of a file's bytes. + * + * The partial counterpart to `PUT` on this path, which replaces the whole file. + * Correcting one line without regenerating everything around it is the point: + * a whole-file rewrite of a long document both costs more and drifts. + * + * Exact replacement refuses ambiguity unless `replaceAll` is explicit. + * Anchored edits match complete trimmed lines, so they remain stable when + * unrelated changes move the target to a different line number. + */ +export const PATCH = defineV2JsonRoute({ + contract: v2EditFileContentContract, + auth: v2ApiKeyAuth, + operation: fileOperations.updateContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + parseOptions: { + maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, + }, + beforeParse: async ({ principal, params }) => { + if (typeof params.fileId === 'string') { + await admitUpdateWorkspaceFileContent(principal, params.fileId) + } + }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + edit: body.edit, + }), + useCase: editWorkspaceFileContent, + present: async ({ file, lineCount }) => ({ data: { file: await toV2File(file), lineCount } }), +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/text/route.ts b/apps/sim/app/api/v2/files/[fileId]/text/route.ts index f3a2e543ef4..ded0298ecf0 100644 --- a/apps/sim/app/api/v2/files/[fileId]/text/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/text/route.ts @@ -17,6 +17,10 @@ export const dynamic = 'force-dynamic' * `ppt` parsers deliberately return best-effort content instead of throwing, * so the flag — not an error — is how that is reported. * + * `offset` and `limit` narrow the response to a line window, reported back as + * `lineRange`. `totalLines` there is what separates a file that ended from a + * window that stopped early, so a caller can tell whether to read further. + * * Head-safe: no audit is projected and nothing is written. The read does pull * bytes from object storage, but so does the metadata read beside it, and a * bodiless `HEAD` would answer no useful question here. @@ -31,9 +35,11 @@ export const GET = defineV2JsonRoute({ fileId: params.fileId, assertedWorkspaceId: query.workspaceId, maxBytes: query.maxBytes, + offset: query.offset, + limit: query.limit, }), useCase: readWorkspaceFileText, - present: ({ file, text, truncated, degraded, degradedReason, byteCount }) => ({ + present: ({ file, text, truncated, degraded, degradedReason, byteCount, lineRange }) => ({ data: { fileId: file.id, name: file.name, @@ -44,6 +50,7 @@ export const GET = defineV2JsonRoute({ degradedReason, charCount: text.length, byteCount, + ...(lineRange ? { lineRange } : {}), }, }), }) diff --git a/apps/sim/app/api/v2/files/folders/restore/route.ts b/apps/sim/app/api/v2/files/folders/restore/route.ts index 39b20b78328..9ab8b08f841 100644 --- a/apps/sim/app/api/v2/files/folders/restore/route.ts +++ b/apps/sim/app/api/v2/files/folders/restore/route.ts @@ -3,7 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/ import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { restoreWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' -import { toV2Folder } from '@/app/api/v2/files/folders/utils' +import { toWorkspaceFileFolderPathView } from '@/lib/workspace-files/folder-display-path' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -28,7 +28,7 @@ export const POST = defineV2JsonRoute({ useCase: restoreWorkspaceFileFolderOperation, present: ({ folder, restoredItems }) => ({ data: { - folder: toV2Folder(folder), + folder: toWorkspaceFileFolderPathView(folder), restoredItems: { files: restoredItems.files, folders: restoredItems.folders }, }, }), diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts index c4112dacf92..0b94199347a 100644 --- a/apps/sim/app/api/v2/files/folders/route.test.ts +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -127,11 +127,39 @@ describe('/api/v2/files/folders', () => { search: undefined, sortBy: 'name', sortOrder: 'asc', + recursive: undefined, + depth: undefined, }, request: expect.anything(), }) }) + it('forwards recursive folder traversal controls', async () => { + await GET( + request( + 'GET', + `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&parentPath=%2FReports&recursive=true&depth=2` + ), + context + ) + + expect(mocks.listFolders).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ parentPath: '/Reports', recursive: true, depth: 2 }), + }) + ) + }) + + it('rejects depth without recursive traversal', async () => { + const response = await GET( + request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&depth=2`), + context + ) + + expect(response.status).toBe(400) + expect(mocks.listFolders).not.toHaveBeenCalled() + }) + /** * The archived set is how a caller finds a path to hand to the folder * restore route; without it a recursive delete is unrecoverable over the API. diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index a0059d7a0bb..01cb0931afb 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -13,7 +13,7 @@ import { listWorkspaceFileFoldersOperation, updateWorkspaceFileFolderOperation, } from '@/lib/workspace-files/application/workspace-file-folders' -import { toV2Folder } from '@/app/api/v2/files/folders/utils' +import { toWorkspaceFileFolderPathView } from '@/lib/workspace-files/folder-display-path' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -30,9 +30,14 @@ export const GET = defineV2JsonRoute({ search: query.search, sortBy: query.sortBy, sortOrder: query.sortOrder, + recursive: query.recursive, + depth: query.depth, }), useCase: listWorkspaceFileFoldersOperation, - present: ({ folders }) => ({ data: folders.map(toV2Folder), nextCursor: null }), + present: ({ folders }) => ({ + data: folders.map(toWorkspaceFileFolderPathView), + nextCursor: null, + }), }) export const POST = defineV2JsonRoute({ @@ -43,7 +48,7 @@ export const POST = defineV2JsonRoute({ errorPolicy: v2FileErrorPolicies.default, mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }), useCase: createWorkspaceFileFolderOperation, - present: ({ folder }) => ({ data: toV2Folder(folder) }), + present: ({ folder }) => ({ data: toWorkspaceFileFolderPathView(folder) }), }) export const PATCH = defineV2JsonRoute({ @@ -58,7 +63,7 @@ export const PATCH = defineV2JsonRoute({ destinationPath: body.destinationPath, }), useCase: updateWorkspaceFileFolderOperation, - present: ({ folder }) => ({ data: toV2Folder(folder) }), + present: ({ folder }) => ({ data: toWorkspaceFileFolderPathView(folder) }), }) export const DELETE = defineV2JsonRoute({ diff --git a/apps/sim/app/api/v2/files/folders/utils.ts b/apps/sim/app/api/v2/files/folders/utils.ts deleted file mode 100644 index 760edbad57b..00000000000 --- a/apps/sim/app/api/v2/files/folders/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { buildFolderPath, parentFolderPath, parseFolderPath } from '@/lib/folders/paths' -import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' - -/** - * Projects a stored folder onto the wire shape. - * - * Shared rather than copied per route: the second copy was written without the - * name/path invariant below, so the same row that made the list read fail loudly - * would have been served with a mismatched `name` and `path` from the restore - * read. One definition means one answer. - */ -export function toV2Folder(folder: { - name: string - path: string - createdAt: Date - updatedAt: Date -}) { - const segments = folder.path.startsWith('/') - ? parseFolderPath(folder.path) - : parseWorkspaceFileFolderDisplayPath(folder.path) - if (segments.at(-1) !== folder.name) { - throw new Error('Workspace file folder path does not match its folder name') - } - const path = buildFolderPath(segments) - return { - name: folder.name, - path, - parentPath: parentFolderPath(path), - createdAt: folder.createdAt.toISOString(), - updatedAt: folder.updatedAt.toISOString(), - } -} diff --git a/apps/sim/app/api/v2/files/search/route.ts b/apps/sim/app/api/v2/files/search/route.ts new file mode 100644 index 00000000000..049f1469a8c --- /dev/null +++ b/apps/sim/app/api/v2/files/search/route.ts @@ -0,0 +1,39 @@ +import { parseFolderPathList, v2SearchFileContentContract } from '@/lib/api/contracts/v2/files' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { searchWorkspaceFileContent } from '@/lib/workspace-files/application/search-workspace-file-content' + +export const dynamic = 'force-dynamic' + +/** + * GET /api/v2/files/search — search the indexed text of workspace files. + * + * `folderPaths` confines the search to a folder tree, and narrows the reported + * index coverage with it, so `complete` describes the folders searched. That + * matters for a caller deciding whether a fact is missing: an incomplete index + * means unknown, not absent. + * + * Head-safe: nothing is written and no audit is projected, matching the other + * file read operations. + */ +export const GET = defineV2JsonRoute({ + contract: v2SearchFileContentContract, + auth: v2ApiKeyAuth, + operation: fileOperations.searchContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + query: query.query, + mode: query.mode, + maxResults: query.maxResults, + folderPaths: + query.folderPaths === undefined ? undefined : parseFolderPathList(query.folderPaths), + includeSubfolders: query.includeSubfolders, + }), + useCase: searchWorkspaceFileContent, + present: ({ results, count, truncated, complete, indexStatus }) => ({ + data: { results, count, truncated, complete, indexStatus }, + }), +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts b/apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts index 3db8fed29f4..83da66a883e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts @@ -3,7 +3,10 @@ import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { useQueryClient } from '@tanstack/react-query' import { useWorkspaceInvalidationRoom } from '@/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room' -import { invalidateWorkspaceFileBrowsers } from '@/hooks/queries/workspace-file-folders' +import { + invalidateWorkspaceFileBrowsers, + WORKSPACE_FILE_BROWSER_INVALIDATION_KEY, +} from '@/hooks/queries/workspace-file-folders' /** * Keeps the file browser live: joins the workspace-files room so a `workspace-files-changed` @@ -12,7 +15,10 @@ import { invalidateWorkspaceFileBrowsers } from '@/hooks/queries/workspace-file- */ export function useWorkspaceFilesRoom(workspaceId: string): void { const queryClient = useQueryClient() - useWorkspaceInvalidationRoom(workspaceId, ROOM_TYPES.WORKSPACE_FILES, () => - invalidateWorkspaceFileBrowsers(queryClient, workspaceId) + useWorkspaceInvalidationRoom( + workspaceId, + ROOM_TYPES.WORKSPACE_FILES, + () => invalidateWorkspaceFileBrowsers(queryClient, workspaceId), + WORKSPACE_FILE_BROWSER_INVALIDATION_KEY ) } diff --git a/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.test.tsx b/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.test.tsx new file mode 100644 index 00000000000..e24bf0965e4 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.test.tsx @@ -0,0 +1,133 @@ +/** + * @vitest-environment jsdom + */ + +import { act } from 'react' +import type { RoomType } from '@sim/realtime-protocol/rooms' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockUseSocket } = vi.hoisted(() => ({ mockUseSocket: vi.fn() })) + +vi.mock('@/app/workspace/providers/socket-provider', () => ({ + useSocket: () => mockUseSocket(), +})) + +import { useWorkspaceInvalidationRoom } from '@/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room' + +function fakeSocket() { + return { connected: true, emit: vi.fn(), on: vi.fn(), off: vi.fn() } +} + +let socket: ReturnType +const roots: Root[] = [] + +function mount( + workspaceId: string, + roomType: RoomType, + onChanged: () => void = () => {}, + dedupeKey?: string +): Root { + function Probe() { + useWorkspaceInvalidationRoom(workspaceId, roomType, onChanged, dedupeKey) + return null + } + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + act(() => root.render()) + roots.push(root) + return root +} + +function unmount(root: Root) { + act(() => root.unmount()) +} + +const emitted = (event: string, workspaceId?: string) => + socket.emit.mock.calls.filter( + ([name, payload]) => + name === event && (workspaceId === undefined || payload?.workspaceId === workspaceId) + ).length + +describe('useWorkspaceInvalidationRoom', () => { + beforeEach(() => { + vi.clearAllMocks() + socket = fakeSocket() + mockUseSocket.mockImplementation(() => ({ socket })) + }) + + afterEach(() => { + for (const root of roots.splice(0)) { + try { + unmount(root) + } catch { + /* already unmounted by the test */ + } + } + }) + + it('joins the room', () => { + mount('ws-1', 'workspace-files') + + expect(emitted('join-workspace-files')).toBe(1) + }) + + /* + * An editor can show several folder pickers, each subscribing to the same + * room. Without reference counting the first to unmount emitted `leave` and + * evicted the socket, so its still-mounted siblings silently stopped + * receiving updates. + */ + it('does not leave while another subscriber is still mounted', () => { + const first = mount('ws-1', 'workspace-files') + mount('ws-1', 'workspace-files') + + unmount(first) + + expect(emitted('leave-workspace-files')).toBe(0) + }) + + it('shares one socket handler and one callback per dedupe key', () => { + const onChanged = vi.fn() + mount('ws-1', 'workspace-files', onChanged, 'file-browser') + mount('ws-1', 'workspace-files', onChanged, 'file-browser') + + const changedHandlers = socket.on.mock.calls.filter( + ([event]) => event === 'workspace-files-changed' + ) + expect(changedHandlers).toHaveLength(1) + + act(() => changedHandlers[0][1]({ workspaceId: 'ws-1' })) + expect(onChanged).toHaveBeenCalledTimes(1) + }) + + it('leaves once the last subscriber unmounts', () => { + const first = mount('ws-1', 'workspace-files') + const second = mount('ws-1', 'workspace-files') + + unmount(first) + unmount(second) + + expect(emitted('leave-workspace-files')).toBe(1) + }) + + it('leaves a room whose own subscriber went, while another room is still held', () => { + const files = mount('ws-1', 'workspace-files') + mount('ws-1', 'workspace-tables') + + unmount(files) + + expect(emitted('leave-workspace-files')).toBe(1) + expect(emitted('leave-workspace-tables')).toBe(0) + }) + + it('does not let one workspace hold another open', () => { + const a = mount('ws-1', 'workspace-files') + mount('ws-2', 'workspace-files') + + unmount(a) + + expect(emitted('leave-workspace-files', 'ws-1')).toBe(1) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts b/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts index 0c652fcb8d6..ce0c56da60d 100644 --- a/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts +++ b/apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts @@ -3,6 +3,7 @@ import { useEffect, useRef } from 'react' import { createLogger } from '@sim/logger' import type { RoomType } from '@sim/realtime-protocol/rooms' +import type { Socket } from 'socket.io-client' import { useSocket } from '@/app/workspace/providers/socket-provider' const logger = createLogger('WorkspaceInvalidationRoom') @@ -18,6 +19,72 @@ interface JoinErrorPayload { retryable?: boolean } +interface SharedRoomSubscription { + callbacks: Map void>> + dispose: () => void +} + +const subscriptionsBySocket = new WeakMap>() + +function createSharedRoomSubscription( + socket: Socket, + workspaceId: string, + roomType: RoomType +): SharedRoomSubscription { + const joinEvent = `join-${roomType}` + const successEvent = `${joinEvent}-success` + const errorEvent = `${joinEvent}-error` + const leaveEvent = `leave-${roomType}` + const changedEvent = `${roomType}-changed` + const callbacks = new Map void>>() + let retries = 0 + let retryTimer: ReturnType | null = null + + const join = () => socket.emit(joinEvent, { workspaceId }) + const handleConnect = () => { + retries = 0 + if (retryTimer) clearTimeout(retryTimer) + retryTimer = null + join() + } + const handleJoinSuccess = (data: { workspaceId: string }) => { + if (data.workspaceId !== workspaceId) return + retries = 0 + if (retryTimer) clearTimeout(retryTimer) + retryTimer = null + } + const handleJoinError = (data: JoinErrorPayload) => { + if (data.workspaceId !== workspaceId) return + logger.warn(`Failed to join ${roomType} room`, { code: data.code, error: data.error }) + if (!data.retryable || retries >= MAX_JOIN_RETRIES) return + retries += 1 + if (retryTimer) clearTimeout(retryTimer) + retryTimer = setTimeout(join, JOIN_RETRY_BASE_MS * retries) + } + const handleChanged = (data: { workspaceId: string }) => { + if (data.workspaceId !== workspaceId) return + for (const group of callbacks.values()) group.values().next().value?.() + } + + if (socket.connected) join() + socket.on('connect', handleConnect) + socket.on(successEvent, handleJoinSuccess) + socket.on(errorEvent, handleJoinError) + socket.on(changedEvent, handleChanged) + + return { + callbacks, + dispose: () => { + if (retryTimer) clearTimeout(retryTimer) + socket.off('connect', handleConnect) + socket.off(successEvent, handleJoinSuccess) + socket.off(errorEvent, handleJoinError) + socket.off(changedEvent, handleChanged) + socket.emit(leaveEvent, { workspaceId }) + }, + } +} + /** * Joins a workspace-scoped, presence-free "invalidation room" over the shared socket and runs * `onChanged` whenever the server broadcasts `${roomType}-changed` for this workspace, so the list @@ -30,82 +97,41 @@ interface JoinErrorPayload { export function useWorkspaceInvalidationRoom( workspaceId: string, roomType: RoomType, - onChanged: () => void + onChanged: () => void, + dedupeKey?: string ): void { const { socket } = useSocket() - // Held by ref so a caller passing a fresh closure each render never re-subscribes the socket. const onChangedRef = useRef(onChanged) onChangedRef.current = onChanged + const privateCallbackKeyRef = useRef(Symbol('workspace-invalidation-callback')) useEffect(() => { if (!socket || !workspaceId) return - - const joinEvent = `join-${roomType}` - const successEvent = `${joinEvent}-success` - const errorEvent = `${joinEvent}-error` - const leaveEvent = `leave-${roomType}` - const changedEvent = `${roomType}-changed` - - let retries = 0 - let retryTimer: ReturnType | null = null - - const join = () => socket.emit(joinEvent, { workspaceId }) - - // A fresh (re)connect gets a fresh retry budget, so a prior full exhaustion doesn't leave the - // socket unable to retry a failed re-join until the next success. Cancel any retry still pending - // from before the reconnect so it can't fire a duplicate join after this immediate one. - const handleConnect = () => { - retries = 0 - if (retryTimer) { - clearTimeout(retryTimer) - retryTimer = null - } - join() - } - - const handleJoinSuccess = (data: { workspaceId: string }) => { - if (data.workspaceId !== workspaceId) return - retries = 0 - // Cancel any retry scheduled by a prior retryable error so it can't fire an extra - // join after we're already in. - if (retryTimer) { - clearTimeout(retryTimer) - retryTimer = null - } - } - const handleJoinError = (data: JoinErrorPayload) => { - if (data.workspaceId !== workspaceId) return - logger.warn(`Failed to join ${roomType} room`, { code: data.code, error: data.error }) - if (data.retryable && retries < MAX_JOIN_RETRIES) { - retries += 1 - // Clear any still-pending retry before scheduling a new one, so reconnect churn can't - // orphan a timer that fires an extra join(). - if (retryTimer) clearTimeout(retryTimer) - retryTimer = setTimeout(join, JOIN_RETRY_BASE_MS * retries) - } + const subscriberKey = `${workspaceId}|${roomType}` + let socketSubscriptions = subscriptionsBySocket.get(socket) + if (!socketSubscriptions) { + socketSubscriptions = new Map() + subscriptionsBySocket.set(socket, socketSubscriptions) } - const handleChanged = (data: { workspaceId: string }) => { - if (data.workspaceId === workspaceId) onChangedRef.current() + let subscription = socketSubscriptions.get(subscriberKey) + if (!subscription) { + subscription = createSharedRoomSubscription(socket, workspaceId, roomType) + socketSubscriptions.set(subscriberKey, subscription) } - // Join now if the socket is already connected; `connect` covers (re)connects. - if (socket.connected) join() - socket.on('connect', handleConnect) - socket.on(successEvent, handleJoinSuccess) - socket.on(errorEvent, handleJoinError) - socket.on(changedEvent, handleChanged) + const callbackKey = dedupeKey ?? privateCallbackKeyRef.current + const callback = () => onChangedRef.current() + const callbackGroup = subscription.callbacks.get(callbackKey) ?? new Set() + callbackGroup.add(callback) + subscription.callbacks.set(callbackKey, callbackGroup) return () => { - if (retryTimer) clearTimeout(retryTimer) - socket.off('connect', handleConnect) - socket.off(successEvent, handleJoinSuccess) - socket.off(errorEvent, handleJoinError) - socket.off(changedEvent, handleChanged) - - // Leave the room, scoped to THIS workspace: the server no-ops if the socket has already - // switched to another workspace's room (so a workspace A→B switch, where B's join runs first - // and auto-leaves A, can't have A's leave evict B). - socket.emit(leaveEvent, { workspaceId }) + callbackGroup.delete(callback) + if (callbackGroup.size === 0) subscription.callbacks.delete(callbackKey) + if (subscription.callbacks.size > 0) return + subscription.dispose() + socketSubscriptions.delete(subscriberKey) + if (socketSubscriptions.size === 0) subscriptionsBySocket.delete(socket) } - }, [socket, workspaceId, roomType]) + }, [dedupeKey, socket, workspaceId, roomType]) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx index 2b7356c6697..9f68beace28 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/file-upload/file-upload.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Button, Combobox, cn } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { createLogger } from '@sim/logger' @@ -13,9 +13,16 @@ import { Progress } from '@/components/ui/progress' 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 { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { readFolderPaths } from '@/lib/folders/selection' +import { formatFileSize, getExtensionFromMimeType } from '@/lib/uploads/utils/file-utils' +import { containsReference } from '@/lib/workflows/sanitization/references' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' +import { isFileInFolderScope } from '@/lib/workspace-files/folder-path-selection' +import { findSelectedWorkspaceFile } from '@/lib/workspace-files/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 { useResourceFolders } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-resource-folders' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' import { useActiveSearchTarget } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/providers/active-search-target-provider' import { @@ -45,6 +52,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; manualFieldId?: string; recursiveFieldId?: string } /** * Controlled value. When `onValueChange` is provided the component reads from * this prop and writes through `onValueChange` instead of the subblock store, @@ -55,12 +67,60 @@ 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 + } +} + +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) +} + +/** Uses the shared formatter while preserving exact values below one kilobyte. */ +function workspaceFileSizeLabel(bytes: number): string { + return formatFileSize(bytes, { includeBytes: true }) +} + 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 { @@ -99,7 +159,7 @@ function SingleFileSelector({ isDeleting, workflowSearchHighlight, }: SingleFileSelectorProps) { - const displayLabel = `${truncateMiddle(file.name, 20, 12)} (${formatFileSize(file.size)})` + const displayLabel = `${truncateMiddle(file.name, 20, 12)} (${workspaceFileSizeLabel(file.size)})` const [searchQuery, setSearchQuery] = useState('') const [isEditing, setIsEditing] = useState(false) // When not editing, always show the file's display label. When editing, show the user's query. @@ -180,6 +240,7 @@ export function FileUpload({ isPreview = false, previewValue, disabled = false, + folderScope, value: controlledValue, onValueChange, }: FileUploadProps) { @@ -191,14 +252,17 @@ export function FileUpload({ * Persists a new value. In controlled mode the caller owns persistence; in * store mode we write through the subblock store and notify collaborators. */ - const commitValue = (next: UploadedFile | UploadedFile[] | null) => { - if (isControlled) { - onValueChange(next) - return - } - setStoreValue(next) - useWorkflowStore.getState().triggerUpdate() - } + const commitValue = useCallback( + (next: UploadedFile | UploadedFile[] | null) => { + if (isControlled) { + onValueChange(next) + return + } + setStoreValue(next) + useWorkflowStore.getState().triggerUpdate() + }, + [isControlled, onValueChange, setStoreValue] + ) const [modelValue] = useSubBlockValue(blockId, 'model') const [uploadingFiles, setUploadingFiles] = useState([]) const [uploadProgress, setUploadProgress] = useState(0) @@ -216,6 +280,7 @@ export function FileUpload({ const { data: workspaceFiles = [], isLoading: loadingWorkspaceFiles, + isPlaceholderData: workspaceFilesArePlaceholderData, refetch: refetchWorkspaceFiles, } = useWorkspaceFiles(isPreview ? '' : workspaceId) @@ -232,6 +297,10 @@ export function FileUpload({ const queryClient = useQueryClient() const value = isControlled ? controlledValue : isPreview ? previewValue : storeValue + const filesArray = useMemo( + () => (Array.isArray(value) ? value : value ? [value] : []), + [value] + ) const maxSizeInBytes = useMemo(() => { const fallback = maxSize * 1024 * 1024 @@ -274,18 +343,135 @@ export function FileUpload({ }) } - const availableWorkspaceFiles = workspaceFiles.filter((workspaceFile) => { - const existingFiles = Array.isArray(value) ? value : value ? [value] : [] + /* + * 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(blockId, folderScope?.fieldId ?? subBlockId) + /* + * Through `readFolderPaths` rather than a string check so current arrays and + * legacy serialized arrays resolve to the same canonical scopes. + */ + const [manualFolderScopeValue] = useSubBlockValue( + blockId, + folderScope?.manualFieldId ?? folderScope?.fieldId ?? subBlockId + ) + const folderScopePaths = useMemo(() => { + if (!folderScope) return [] + const selectedPaths = readFolderPaths(folderScopeValue) + return selectedPaths.length > 0 ? selectedPaths : readFolderPaths(manualFolderScopeValue) + }, [folderScope, folderScopeValue, manualFolderScopeValue]) + + const [folderScopeRecursive] = useSubBlockValue( + blockId, + folderScope?.recursiveFieldId ?? subBlockId + ) + /* + * Absent means "descend", matching the switch's own default, so an unset or + * not-yet-rendered value never narrows the options behind the user's back. + */ + const folderScopeIncludesSubfolders = + !folderScope?.recursiveFieldId || + folderScopeRecursive === undefined || + folderScopeRecursive === null || + folderScopeRecursive === '' || + folderScopeRecursive === true || + folderScopeRecursive === 'true' + + const hasConcreteFolderScope = + folderScopePaths.length > 0 && folderScopePaths.every((path) => !containsReference(path)) + const { + byPath: folderByPath, + isLoading: loadingFolderScope, + isPlaceholderData: folderScopeIsPlaceholderData, + error: folderScopeError, + } = useResourceFolders(folderScope && !isPreview ? workspaceId : undefined, 'file') + const uploadTargetFolderId = useMemo(() => { + if (!folderScope || folderScopePaths.length === 0) return null + if (!hasConcreteFolderScope || folderScopePaths.length !== 1) return undefined + if (folderScopePaths[0] === ROOT_FOLDER_PATH) return null + if (loadingFolderScope || folderScopeIsPlaceholderData || folderScopeError) return undefined + return folderByPath.get(folderScopePaths[0])?.id + }, [ + folderByPath, + folderScope, + folderScopeError, + folderScopeIsPlaceholderData, + folderScopePaths, + hasConcreteFolderScope, + loadingFolderScope, + ]) + const folderScopeUploadBlocked = uploadTargetFolderId === undefined + const scopedWorkspaceFiles = useMemo( + () => + hasConcreteFolderScope + ? workspaceFiles.filter((workspaceFile) => + folderScopePaths.some((folderScopePath) => + isFileInFolderScope(workspaceFile.folderPath, folderScopePath, { + includeSubfolders: folderScopeIncludesSubfolders, + }) + ) + ) + : workspaceFiles, + [folderScopeIncludesSubfolders, folderScopePaths, hasConcreteFolderScope, workspaceFiles] + ) + + const selectedWorkspaceFileIds = useMemo( + () => + new Set( + filesArray.flatMap((file) => { + const match = findSelectedWorkspaceFile(file, workspaceFiles) + return match ? [match.id] : [] + }) + ), + [filesArray, workspaceFiles] + ) - const isAlreadySelected = existingFiles.some( - (existing) => - existing.name === workspaceFile.name || - existing.path?.includes(workspaceFile.key) || - existing.key === workspaceFile.key - ) + useEffect(() => { + if ( + !folderScope || + !hasConcreteFolderScope || + loadingWorkspaceFiles || + workspaceFilesArePlaceholderData || + isPreview + ) { + return + } - return !isAlreadySelected - }) + const scopedIds = new Set(scopedWorkspaceFiles.map((file) => file.id)) + const nextFiles = filesArray.filter((file) => { + const workspaceFile = findSelectedWorkspaceFile(file, workspaceFiles) + return !workspaceFile || scopedIds.has(workspaceFile.id) + }) + if (nextFiles.length === filesArray.length) return + + commitValue(multiple ? (nextFiles.length > 0 ? nextFiles : null) : (nextFiles[0] ?? null)) + }, [ + commitValue, + filesArray, + folderScope, + hasConcreteFolderScope, + isPreview, + loadingWorkspaceFiles, + multiple, + scopedWorkspaceFiles, + workspaceFiles, + workspaceFilesArePlaceholderData, + ]) + + const availableWorkspaceFiles = useMemo( + () => + scopedWorkspaceFiles.filter( + (workspaceFile) => !selectedWorkspaceFileIds.has(workspaceFile.id) + ), + [scopedWorkspaceFiles, selectedWorkspaceFileIds] + ) /** * Opens file dialog @@ -294,7 +480,7 @@ export function FileUpload({ e.preventDefault() e.stopPropagation() - if (disabled || cloudUploadBlocked) return + if (disabled || cloudUploadBlocked || folderScopeUploadBlocked) return if (fileInputRef.current) { fileInputRef.current.value = '' @@ -302,15 +488,6 @@ export function FileUpload({ } } - /** - * Formats file size for display in a human-readable format - */ - const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB` - return `${(bytes / (1024 * 1024)).toFixed(1)} MB` - } - /** * Truncate long file names keeping both start and end segments. */ @@ -324,7 +501,7 @@ export function FileUpload({ * Handles file upload when new file(s) are selected */ const handleFileChange = async (e: React.ChangeEvent) => { - if (isPreview || disabled || cloudUploadBlocked) return + if (isPreview || disabled || cloudUploadBlocked || folderScopeUploadBlocked) return e.stopPropagation() @@ -384,6 +561,7 @@ export function FileUpload({ const data = await uploadFileMutation.mutateAsync({ workspaceId, file, + folderId: uploadTargetFolderId, skipToast: true, skipInvalidation: true, }) @@ -392,6 +570,7 @@ export function FileUpload({ name: data.file.name, path: data.file.url, key: data.file.key, + id: data.file.id, size: data.file.size, type: data.file.type, }) @@ -486,6 +665,8 @@ export function FileUpload({ key: selectedFile.key, size: selectedFile.size, type: selectedFile.type, + id: selectedFile.id, + folderPath: selectedFile.folderPath ?? undefined, } if (multiple) { @@ -576,7 +757,9 @@ export function FileUpload({ {formatDisplayText(displayName, { workflowSearchHighlight })} - ({formatFileSize(file.size)}) + + ({workspaceFileSizeLabel(file.size)}) +