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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions apps/docs/content/docs/integrations/file.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Read workspace file objects from selected files or canonical workspace file IDs.
| --------- | ---- | -------- | ----------- |
| `fileId` | string | No | Canonical workspace file ID, or an array of canonical workspace file IDs. |
| `fileInput` | file | No | Selected workspace file object. |
| `folderPaths` | array | No | Folders whose files are included, as canonical percent-encoded paths, e.g. \["/Reports/Q3%20Results"\]. Nested folders are included by default, and the folders are read at run time, so a file added later is picked up. |
| `includeSubfolders` | boolean | No | Whether nested folders are read too. Defaults to true; set false to take only the folders’ direct files. |

#### Output

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

#### Output

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

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

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

### List Files and Folders

List what is inside a workspace folder: its subfolders and its files together. Lists direct children by default; set Recursive to walk the whole subtree.

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | No | Folder to list. Omit to list from the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
| `recursive` | boolean | No | List everything beneath the path rather than only its direct children. Each entry carries its depth below the listed folder. |
| `depth` | number | No | Deepest level to include when recursive, counted from the listed folder. 1 is direct children. |
| `search` | string | No | Case-insensitive substring match against an entry name. Filters the result, so a deep match is still reported even when its parent folders do not match. |
| `limit` | number | No | Most entries to return, 200 by default. A listing cut short comes back with truncated set. |

#### Output

| Parameter | Type | Description |
| --------- | ---- | ----------- |
| `path` | string | The folder that was listed. |
| `entries` | array | What the folder holds. Each entry has kind "folder" or "file", a name, and its depth below the listed folder. A folder carries its own canonical path; a file carries its id, size, type, and the canonical path of the folder holding it. |
| `truncated` | boolean | True when the limit cut the listing short, so more entries exist. |

### Create File Folder

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

#### Input

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

#### Output

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

### Move File Folder

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

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `path` | string | Yes | Folder to move. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |
| `destinationPath` | string | Yes | Full path the folder should have afterwards. Renaming is a destination whose parent is unchanged. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |

#### Output

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

### Delete File Folder

Delete a workspace file folder. It moves to Recently deleted and can be brought back with Restore File Folder. Deleting a folder that still has contents requires the recursive option.

#### Input

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

#### Output

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

### Restore File Folder

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

#### Input

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

#### Output

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

### Move File

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

#### Input

| Parameter | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `fileId` | string | Yes | Canonical workspace file ID of the file to move. |
| `folderPath` | string | No | Destination folder. Omit to move the file to the workspace root. Canonical folder path, percent-encoded, e.g. "/Reports/Q3%20Results". The workspace root is "/". |

#### Output

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


Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @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<typeof fakeSocket>
const roots: Root[] = []

function mount(workspaceId: string, roomType: RoomType): Root {
function Probe() {
useWorkspaceInvalidationRoom(workspaceId, roomType, () => {})
return null
}
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
act(() => root.render(<Probe />))
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('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)
})

/* Counted per room, so holding one open does not hold another. */
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)
})

/* And per workspace, so one workspace cannot hold another one open. */
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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import { useSocket } from '@/app/workspace/providers/socket-provider'

const logger = createLogger('WorkspaceInvalidationRoom')

/**
* How many hooks currently want each `workspaceId|roomType` room.
*
* A room is shared: an editor can show several folder pickers, each calling
* this hook for the same workspace. Joining twice is harmless because a room is
* a set, but LEAVING is not — the first picker to unmount would emit `leave`
* and evict the socket while its siblings were still listening, so they simply
* stopped receiving updates. Counting subscribers means the room is left once
* the last one goes, which is the only point at which leaving is correct.
*/
const roomSubscribers = new Map<string, number>()

/** Retry cap + base delay for a retryable join failure on an otherwise-live socket. */
const MAX_JOIN_RETRIES = 3
const JOIN_RETRY_BASE_MS = 1000
Expand Down Expand Up @@ -46,6 +58,10 @@ export function useWorkspaceInvalidationRoom(
const leaveEvent = `leave-${roomType}`
const changedEvent = `${roomType}-changed`

const subscriberKey = `${workspaceId}|${roomType}`
const subscribers = (roomSubscribers.get(subscriberKey) ?? 0) + 1
roomSubscribers.set(subscriberKey, subscribers)

let retries = 0
let retryTimer: ReturnType<typeof setTimeout> | null = null

Expand Down Expand Up @@ -102,6 +118,14 @@ export function useWorkspaceInvalidationRoom(
socket.off(errorEvent, handleJoinError)
socket.off(changedEvent, handleChanged)

// Leave only once the last subscriber to this room has gone; see `roomSubscribers`.
const remaining = (roomSubscribers.get(subscriberKey) ?? 1) - 1
if (remaining > 0) {
roomSubscribers.set(subscriberKey, remaining)
return
}
roomSubscribers.delete(subscriberKey)

// 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).
Expand Down
Loading
Loading