Skip to content

Commit 3b155c3

Browse files
committed
fix(files): close folder scope review gaps
1 parent 22b3528 commit 3b155c3

33 files changed

Lines changed: 521 additions & 251 deletions

File tree

apps/docs/content/docs/cli/files.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,8 @@ Also available as `sim files folders ls`.
108108
| `--sort-by <value>` | 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`. |
109109
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
110110
| `--scope <value>` | 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`. |
111+
| `--recursive <value>` | 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`. |
112+
| `--depth <value>` | No | Deepest level below parentPath to include when recursive is true. |
111113

112114
</CommandTable>
113115

apps/docs/content/docs/cli/reference.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,8 @@ Also available as `sim files folders ls`.
788788
| `--sort-by <value>` | 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`. |
789789
| `--sort-order <value>` | No | Sort direction. Accepted values: `asc`, `desc`. |
790790
| `--scope <value>` | 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`. |
791+
| `--recursive <value>` | 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`. |
792+
| `--depth <value>` | No | Deepest level below parentPath to include when recursive is true. |
791793

792794
</CommandTable>
793795

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

Lines changed: 8 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -191,33 +191,10 @@ Replace one exact piece of text in an existing workspace file, leaving the rest
191191

192192
| Parameter | Type | Description |
193193
| --------- | ---- | ----------- |
194-
| `files` | file[] | Workspace file objects with share status \(read\), fetched file objects \(fetch\), the compressed archive \(compress\), or extracted files \(decompress\) |
195-
| `contents` | array | Array of file text contents, one entry per file \(get content\) |
196-
| `lineRanges` | array | Line window returned per file when a range was requested, as objects with offset, lineCount, and totalLines \(get content\) |
197-
| `lineCount` | number | Lines in the file after the change \(edit, insert\) |
198-
| `results` | array | Matching lines as objects with fileId, lineNumber, and text fields \(search\) |
199-
| `count` | number | Returned matching line count \(search\) |
200-
| `truncated` | boolean | Whether results were cut short by a cap: more matches exist \(search\), or more entries exist \(list\) |
201-
| `complete` | boolean | Whether the searched scope has no file still pending or failed indexing. It does not cover skipped or partial files, so check indexStatus too before treating a missing match as authoritative \(search\) |
202-
| `indexStatus` | json | Workspace search-index coverage counts: readyFiles, pendingFiles, failedFiles, skippedFiles, and partialFiles |
203-
| `combinedContent` | string | All fetched file contents merged into a single text string \(fetch\) |
204-
| `id` | string | File ID \(write and append\) |
205-
| `name` | string | File name \(write and append\) |
206-
| `size` | number | File size in bytes \(write and append\) |
207-
| `url` | string | URL to access the file \(write and append\), or the public share link when shared; empty when set to private \(manage sharing\) |
208-
| `isActive` | boolean | Whether the public link is enabled \(manage sharing\) |
209-
| `entries` | array | What the folder holds, each with kind "folder" or "file", a name, and its depth below the listed folder \(list\) |
210-
| `folder` | json | The affected folder \(create, move, delete, and restore folder\) |
211-
| `path` | string | The folder that was listed or deleted \(list and delete folder\) |
212-
| `previousPath` | string | The path the folder had before it moved \(move folder\) |
213-
| `deleted` | boolean | Whether the folder was deleted \(delete folder\) |
214-
| `deletedItems` | json | Counts of folders and files deleted alongside the folder \(delete folder\) |
215-
| `fileId` | string | The file that was moved \(move file\) |
216-
| `folderPath` | string | The folder the file now lives in \(move file\) |
217-
| `restoredItems` | json | Counts of folders and files restored alongside the folder \(restore folder\) |
218-
| `authType` | string | Public link access mode: public, password, email, or sso \(manage sharing\) |
219-
| `hasPassword` | boolean | Whether the public link is password-protected \(manage sharing\) |
220-
| `allowedEmails` | array | Allowed emails/domains for email or SSO access \(manage sharing\) |
194+
| `id` | string | File ID |
195+
| `name` | string | File name |
196+
| `size` | number | File size in bytes |
197+
| `lineCount` | number | Lines in the file after the edit |
221198

222199
### File Insert
223200

@@ -238,33 +215,10 @@ Insert new lines into an existing workspace file at a given line, leaving the re
238215

239216
| Parameter | Type | Description |
240217
| --------- | ---- | ----------- |
241-
| `files` | file[] | Workspace file objects with share status \(read\), fetched file objects \(fetch\), the compressed archive \(compress\), or extracted files \(decompress\) |
242-
| `contents` | array | Array of file text contents, one entry per file \(get content\) |
243-
| `lineRanges` | array | Line window returned per file when a range was requested, as objects with offset, lineCount, and totalLines \(get content\) |
244-
| `lineCount` | number | Lines in the file after the change \(edit, insert\) |
245-
| `results` | array | Matching lines as objects with fileId, lineNumber, and text fields \(search\) |
246-
| `count` | number | Returned matching line count \(search\) |
247-
| `truncated` | boolean | Whether results were cut short by a cap: more matches exist \(search\), or more entries exist \(list\) |
248-
| `complete` | boolean | Whether the searched scope has no file still pending or failed indexing. It does not cover skipped or partial files, so check indexStatus too before treating a missing match as authoritative \(search\) |
249-
| `indexStatus` | json | Workspace search-index coverage counts: readyFiles, pendingFiles, failedFiles, skippedFiles, and partialFiles |
250-
| `combinedContent` | string | All fetched file contents merged into a single text string \(fetch\) |
251-
| `id` | string | File ID \(write and append\) |
252-
| `name` | string | File name \(write and append\) |
253-
| `size` | number | File size in bytes \(write and append\) |
254-
| `url` | string | URL to access the file \(write and append\), or the public share link when shared; empty when set to private \(manage sharing\) |
255-
| `isActive` | boolean | Whether the public link is enabled \(manage sharing\) |
256-
| `entries` | array | What the folder holds, each with kind "folder" or "file", a name, and its depth below the listed folder \(list\) |
257-
| `folder` | json | The affected folder \(create, move, delete, and restore folder\) |
258-
| `path` | string | The folder that was listed or deleted \(list and delete folder\) |
259-
| `previousPath` | string | The path the folder had before it moved \(move folder\) |
260-
| `deleted` | boolean | Whether the folder was deleted \(delete folder\) |
261-
| `deletedItems` | json | Counts of folders and files deleted alongside the folder \(delete folder\) |
262-
| `fileId` | string | The file that was moved \(move file\) |
263-
| `folderPath` | string | The folder the file now lives in \(move file\) |
264-
| `restoredItems` | json | Counts of folders and files restored alongside the folder \(restore folder\) |
265-
| `authType` | string | Public link access mode: public, password, email, or sso \(manage sharing\) |
266-
| `hasPassword` | boolean | Whether the public link is password-protected \(manage sharing\) |
267-
| `allowedEmails` | array | Allowed emails/domains for email or SSO access \(manage sharing\) |
218+
| `id` | string | File ID |
219+
| `name` | string | File name |
220+
| `size` | number | File size in bytes |
221+
| `lineCount` | number | Lines in the file after the edit |
268222

269223
### File Compress
270224

apps/docs/openapi-v2-files-audit.json

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2403,6 +2403,42 @@
24032403
"type": "string",
24042404
"enum": ["active", "archived"]
24052405
}
2406+
},
2407+
{
2408+
"name": "recursive",
2409+
"in": "query",
2410+
"required": false,
2411+
"description": "Whether parentPath includes every descendant instead of direct children only.",
2412+
"schema": {
2413+
"description": "Whether parentPath includes every descendant instead of direct children only.",
2414+
"enum": [
2415+
"true",
2416+
"1",
2417+
"yes",
2418+
"on",
2419+
"y",
2420+
"enabled",
2421+
"false",
2422+
"0",
2423+
"no",
2424+
"off",
2425+
"n",
2426+
"disabled"
2427+
],
2428+
"type": "string"
2429+
}
2430+
},
2431+
{
2432+
"name": "depth",
2433+
"in": "query",
2434+
"required": false,
2435+
"description": "Deepest level below parentPath to include when recursive is true.",
2436+
"schema": {
2437+
"description": "Deepest level below parentPath to include when recursive is true.",
2438+
"type": "integer",
2439+
"minimum": 1,
2440+
"maximum": 64
2441+
}
24062442
}
24072443
],
24082444
"responses": {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/
33
import { v2FileErrorPolicies } from '@/lib/workspace-files/api'
44
import { fileOperations } from '@/lib/workspace-files/application/operations'
55
import { restoreWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders'
6-
import { toV2Folder } from '@/app/api/v2/files/folders/utils'
6+
import { toWorkspaceFileFolderPathView } from '@/lib/workspace-files/folder-display-path'
77
export const dynamic = 'force-dynamic'
88
export const revalidate = 0
99

@@ -28,7 +28,7 @@ export const POST = defineV2JsonRoute({
2828
useCase: restoreWorkspaceFileFolderOperation,
2929
present: ({ folder, restoredItems }) => ({
3030
data: {
31-
folder: toV2Folder(folder),
31+
folder: toWorkspaceFileFolderPathView(folder),
3232
restoredItems: { files: restoredItems.files, folders: restoredItems.folders },
3333
},
3434
}),

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,39 @@ describe('/api/v2/files/folders', () => {
127127
search: undefined,
128128
sortBy: 'name',
129129
sortOrder: 'asc',
130+
recursive: undefined,
131+
depth: undefined,
130132
},
131133
request: expect.anything(),
132134
})
133135
})
134136

137+
it('forwards recursive folder traversal controls', async () => {
138+
await GET(
139+
request(
140+
'GET',
141+
`/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&parentPath=%2FReports&recursive=true&depth=2`
142+
),
143+
context
144+
)
145+
146+
expect(mocks.listFolders).toHaveBeenCalledWith(
147+
expect.objectContaining({
148+
input: expect.objectContaining({ parentPath: '/Reports', recursive: true, depth: 2 }),
149+
})
150+
)
151+
})
152+
153+
it('rejects depth without recursive traversal', async () => {
154+
const response = await GET(
155+
request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&depth=2`),
156+
context
157+
)
158+
159+
expect(response.status).toBe(400)
160+
expect(mocks.listFolders).not.toHaveBeenCalled()
161+
})
162+
135163
/**
136164
* The archived set is how a caller finds a path to hand to the folder
137165
* restore route; without it a recursive delete is unrecoverable over the API.

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
listWorkspaceFileFoldersOperation,
1414
updateWorkspaceFileFolderOperation,
1515
} from '@/lib/workspace-files/application/workspace-file-folders'
16-
import { toV2Folder } from '@/app/api/v2/files/folders/utils'
16+
import { toWorkspaceFileFolderPathView } from '@/lib/workspace-files/folder-display-path'
1717
export const dynamic = 'force-dynamic'
1818
export const revalidate = 0
1919

@@ -30,9 +30,14 @@ export const GET = defineV2JsonRoute({
3030
search: query.search,
3131
sortBy: query.sortBy,
3232
sortOrder: query.sortOrder,
33+
recursive: query.recursive,
34+
depth: query.depth,
3335
}),
3436
useCase: listWorkspaceFileFoldersOperation,
35-
present: ({ folders }) => ({ data: folders.map(toV2Folder), nextCursor: null }),
37+
present: ({ folders }) => ({
38+
data: folders.map(toWorkspaceFileFolderPathView),
39+
nextCursor: null,
40+
}),
3641
})
3742

3843
export const POST = defineV2JsonRoute({
@@ -43,7 +48,7 @@ export const POST = defineV2JsonRoute({
4348
errorPolicy: v2FileErrorPolicies.default,
4449
mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }),
4550
useCase: createWorkspaceFileFolderOperation,
46-
present: ({ folder }) => ({ data: toV2Folder(folder) }),
51+
present: ({ folder }) => ({ data: toWorkspaceFileFolderPathView(folder) }),
4752
})
4853

4954
export const PATCH = defineV2JsonRoute({
@@ -58,7 +63,7 @@ export const PATCH = defineV2JsonRoute({
5863
destinationPath: body.destinationPath,
5964
}),
6065
useCase: updateWorkspaceFileFolderOperation,
61-
present: ({ folder }) => ({ data: toV2Folder(folder) }),
66+
present: ({ folder }) => ({ data: toWorkspaceFileFolderPathView(folder) }),
6267
})
6368

6469
export const DELETE = defineV2JsonRoute({

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

Lines changed: 0 additions & 32 deletions
This file was deleted.

apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
import { ROOM_TYPES } from '@sim/realtime-protocol/rooms'
44
import { useQueryClient } from '@tanstack/react-query'
55
import { useWorkspaceInvalidationRoom } from '@/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room'
6-
import { invalidateWorkspaceFileBrowsers } from '@/hooks/queries/workspace-file-folders'
6+
import {
7+
invalidateWorkspaceFileBrowsers,
8+
WORKSPACE_FILE_BROWSER_INVALIDATION_KEY,
9+
} from '@/hooks/queries/workspace-file-folders'
710

811
/**
912
* 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-
1215
*/
1316
export function useWorkspaceFilesRoom(workspaceId: string): void {
1417
const queryClient = useQueryClient()
15-
useWorkspaceInvalidationRoom(workspaceId, ROOM_TYPES.WORKSPACE_FILES, () =>
16-
invalidateWorkspaceFileBrowsers(queryClient, workspaceId)
18+
useWorkspaceInvalidationRoom(
19+
workspaceId,
20+
ROOM_TYPES.WORKSPACE_FILES,
21+
() => invalidateWorkspaceFileBrowsers(queryClient, workspaceId),
22+
WORKSPACE_FILE_BROWSER_INVALIDATION_KEY
1723
)
1824
}

apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.test.tsx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ function fakeSocket() {
2222
let socket: ReturnType<typeof fakeSocket>
2323
const roots: Root[] = []
2424

25-
function mount(workspaceId: string, roomType: RoomType): Root {
25+
function mount(
26+
workspaceId: string,
27+
roomType: RoomType,
28+
onChanged: () => void = () => {},
29+
dedupeKey?: string
30+
): Root {
2631
function Probe() {
27-
useWorkspaceInvalidationRoom(workspaceId, roomType, () => {})
32+
useWorkspaceInvalidationRoom(workspaceId, roomType, onChanged, dedupeKey)
2833
return null
2934
}
3035
const container = document.createElement('div')
@@ -83,6 +88,20 @@ describe('useWorkspaceInvalidationRoom', () => {
8388
expect(emitted('leave-workspace-files')).toBe(0)
8489
})
8590

91+
it('shares one socket handler and one callback per dedupe key', () => {
92+
const onChanged = vi.fn()
93+
mount('ws-1', 'workspace-files', onChanged, 'file-browser')
94+
mount('ws-1', 'workspace-files', onChanged, 'file-browser')
95+
96+
const changedHandlers = socket.on.mock.calls.filter(
97+
([event]) => event === 'workspace-files-changed'
98+
)
99+
expect(changedHandlers).toHaveLength(1)
100+
101+
act(() => changedHandlers[0][1]({ workspaceId: 'ws-1' }))
102+
expect(onChanged).toHaveBeenCalledTimes(1)
103+
})
104+
86105
it('leaves once the last subscriber unmounts', () => {
87106
const first = mount('ws-1', 'workspace-files')
88107
const second = mount('ws-1', 'workspace-files')

0 commit comments

Comments
 (0)