diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index 313122ee810..7ecf63239d8 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Runs one end-to-end suite group over real HTTP, each against its own `next dev` app. # -# Usage: http-e2e.sh (run from apps/sim) +# Usage: http-e2e.sh (run from apps/sim) # # The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of # its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e. @@ -17,7 +17,7 @@ # telemetry flush runs detached and still writes .next/dev). set -euo pipefail -group=${1:?usage: http-e2e.sh } +group=${1:?usage: http-e2e.sh } report_dir="$RUNNER_TEMP/e2e" ready_timeout_seconds=300 mkdir -p "$report_dir" @@ -75,6 +75,20 @@ start_app() { } case "$group" in + files) + export NEXT_PUBLIC_FORCE_HOSTED=false STORAGE_PROVIDER=local + export NEXT_PUBLIC_CHAT_DISABLED=true + export INTERNAL_API_SECRET=files-http-ci-local-secret-at-least-32-characters + export CRON_SECRET=files-e2e-cron-secret-at-least-32-characters + start_app files 3027 Files record-http-status + FILES_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \ + FILES_E2E_DATABASE_URL="$DATABASE_URL" \ + FILES_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ + FILES_E2E_CRON_SECRET="$CRON_SECRET" \ + FILES_E2E_REPORT_PATH="$report_dir/files-e2e-report.json" \ + bun --no-env-file scripts/test-files-e2e.ts + ;; + scim) export NEXT_PUBLIC_FORCE_HOSTED=true export BILLING_ENABLED=true NEXT_PUBLIC_BILLING_ENABLED=true diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 7210aa37488..af91b70f005 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -138,8 +138,8 @@ jobs: # The suites exercise HTTP behavior rather than a provisioning path, so they run once, against # the production (migrate) path. Each group's app environment lives in http-e2e.sh. # - # SCIM runs two suites against a hosted app and is the longest group, so it keeps the 8 vCPU - # runner; the others boot a smaller self-hosted app and fit on 4. + # SCIM's hosted app and the Files browser suite use 8 vCPU runners; the smaller self-hosted + # suites fit on 4. e2e: name: e2e (${{ matrix.group }}) runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && matrix.runner || 'ubuntu-latest' }} @@ -156,6 +156,8 @@ jobs: runner: blacksmith-4vcpu-ubuntu-2404 - group: desktop-inbox runner: blacksmith-4vcpu-ubuntu-2404 + - group: files + runner: blacksmith-8vcpu-ubuntu-2404 services: postgres: image: pgvector/pgvector:pg17 @@ -199,6 +201,18 @@ jobs: working-directory: packages/db run: bun run db:migrate + - name: Cache Files browser download + if: matrix.group == 'files' + uses: ./.github/actions/cache + with: + provider: ${{ vars.CI_PROVIDER }} + path: ~/.cache/ms-playwright + key: ${{ github.repository }}-files-browser-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }}-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('bun.lock') }} + + - name: Install Files browser from the pinned Playwright dependency + if: matrix.group == 'files' + run: node node_modules/@playwright/test/cli.js install --with-deps chromium + # No step timeout: the job's bound covers a hang without cutting a slow but healthy suite # short of writing its report. - name: Run end-to-end suites diff --git a/apps/docs/content/docs/integrations/file.mdx b/apps/docs/content/docs/integrations/file.mdx index 0fc151aad4e..34fa44139af 100644 --- a/apps/docs/content/docs/integrations/file.mdx +++ b/apps/docs/content/docs/integrations/file.mdx @@ -28,7 +28,7 @@ In Sim, the File block allows your agents to search, read, and extract text from ## Usage Instructions -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. +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 or folder. @@ -278,6 +278,30 @@ 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 | +### Manage Folder Sharing + +Enable or disable the public share link for a workspace file folder and its current descendants. Set public, password, email, or SSO access. The link stays stable across changes. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `path` | string | Yes | Canonical percent-encoded folder path, e.g. "/Reports/Q3%20Results". The workspace root cannot be shared. | +| `isActive` | boolean | Yes | Whether the public folder link is enabled. Set false to disable access. | +| `authType` | string | No | Access mode: "public", "password", "email", or "sso". Defaults to "public". | +| `password` | string | No | Password to protect the link. Required when authType is "password". | +| `allowedEmails` | array | No | Allowed emails or "@domain" patterns for email or SSO access. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `url` | string | Public folder URL; empty when the link is disabled | +| `isActive` | boolean | Whether the public folder link is enabled | +| `authType` | string | Access mode: public, password, email, or sso | +| `hasPassword` | boolean | Whether the link 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. diff --git a/apps/sim/app/api/files/public/[token]/content/route.test.ts b/apps/sim/app/api/files/public/[token]/content/route.test.ts index ca7640e37c5..e880791fb67 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.test.ts @@ -1,4 +1,6 @@ +import { publicShare, workspaceFiles } from '@sim/db/schema' import { createRouteContext } from '@sim/testing/helpers/http' +import { queueTableRows } from '@sim/testing/mocks/database.mock' import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' import { createMockRequest } from '@sim/testing/mocks/request.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' @@ -41,7 +43,17 @@ const request = (token = 'tok_1') => createMockRequest({ url: `http://localhost/api/files/public/${token}/content` }) const passwordShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, + share: { + id: 'sh_1', + token: 'tok_1', + resourceId: 'wf_1', + workspaceId: 'ws-1', + resourceType: 'file', + isActive: true, + authType: 'password', + password: 'enc:secret', + allowedEmails: [], + }, file: { id: 'wf_1', key: 'workspace/ws/secret-key.pdf', @@ -60,6 +72,8 @@ describe('GET /api/files/public/[token]/content', () => { mockResolveActiveShareByToken.mockResolvedValue(passwordShare) mockDownloadFile.mockResolvedValue(Buffer.from('data')) mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' }) + queueTableRows(publicShare, [{ id: 'sh_1' }]) + queueTableRows(workspaceFiles, [passwordShare.file]) }) it('returns 401 and never reads storage when a password share is unauthorized', async () => { diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index d8fc088918e..68dab9493cb 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -9,8 +9,8 @@ import { generateRequestId } from '@/lib/core/utils/request' import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { resolveServableDoc } from '@/lib/mothership/tools/server/files/doc-compile' +import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' @@ -54,21 +54,11 @@ export const GET = withRouteHandler( const { token } = parsed.data.params const preview = parsed.data.query.preview === '1' - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } + const resolved = await readPublicSharedFile({ + token, + fileId: parsed.data.query.fileId, + authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'), + }) const { file } = resolved // The same ceiling the authenticated serve route reads this object under @@ -161,6 +151,13 @@ export const GET = withRouteHandler( cacheControl: 'private, no-cache, must-revalidate', }) } catch (error) { + if (error instanceof PublicShareAccessError) { + if (error.status === 404) return createErrorResponse(new FileNotFoundError('Not found')) + return NextResponse.json( + { error: error.message }, + { status: error.status, headers: { 'Cache-Control': 'private, no-store' } } + ) + } logger.error('Error serving public shared file:', error) if (error instanceof FileNotFoundError) { return createErrorResponse(error) diff --git a/apps/sim/app/api/files/public/[token]/folder/route.ts b/apps/sim/app/api/files/public/[token]/folder/route.ts new file mode 100644 index 00000000000..2a4d459b4d3 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/folder/route.ts @@ -0,0 +1,46 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getPublicFolderContract } from '@/lib/api/contracts/public-shares' +import { parseRequest } from '@/lib/api/server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PublicShareAccessError, readPublicSharedFolder } from '@/lib/public-shares/access' +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' + +export const dynamic = 'force-dynamic' + +/** Public capabilities use the share authentication protocol rather than workspace membership. */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const headers = { 'Cache-Control': 'private, no-store' } + const limited = await enforcePublicFileRateLimit(request, 'metadata') + if (limited) { + limited.headers.set('Cache-Control', headers['Cache-Control']) + return limited + } + const parsed = await parseRequest(getPublicFolderContract, request, context) + if (!parsed.success) { + parsed.response.headers.set('Cache-Control', headers['Cache-Control']) + return parsed.response + } + try { + const page = await readPublicSharedFolder({ + token: parsed.data.params.token, + ...parsed.data.query, + authorize: (share) => + validateDeploymentAuth(generateRequestId(), share, request, undefined, 'file'), + }) + return NextResponse.json(page, { headers }) + } catch (error) { + if (error instanceof PublicShareAccessError) { + return NextResponse.json({ error: error.message }, { status: error.status, headers }) + } + if (error instanceof OrchestrationError && error.code === 'validation') { + return NextResponse.json({ error: error.message }, { status: 400, headers }) + } + throw error + } + } +) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts index 33bbace8cd1..50016210751 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.test.ts @@ -1,4 +1,6 @@ +import { publicShare, workspaceFiles } from '@sim/db/schema' import { createRouteContext } from '@sim/testing/helpers/http' +import { queueTableRows } from '@sim/testing/mocks/database.mock' import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' import { createMockRequest } from '@sim/testing/mocks/request.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' @@ -38,7 +40,17 @@ const req = (q: string) => createMockRequest({ url: `http://localhost/api/files/public/${TOKEN}/inline?${q}` }) const share = { - share: { id: 'sh_1', token: TOKEN, authType: 'public' }, + share: { + id: 'sh_1', + token: TOKEN, + resourceId: 'wf_doc', + workspaceId: 'ws-1', + resourceType: 'file', + isActive: true, + authType: 'public', + password: null, + allowedEmails: [], + }, file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' }, workspaceName: 'Acme', ownerName: 'Jane', @@ -61,6 +73,8 @@ describe('GET /api/files/public/[token]/inline', () => { filename: 'photo.png', }) mockDownloadFile.mockImplementation(downloadByKey()) + queueTableRows(publicShare, [{ id: 'sh_1' }]) + queueTableRows(workspaceFiles, [share.file]) }) it('rejects exhausted image budgets before share lookup, authentication, or storage reads', async () => { diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 77e52efe587..9b535395b13 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -8,8 +8,8 @@ import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' @@ -61,21 +61,11 @@ export const GET = withRouteHandler( const { token } = parsed.data.params const ref = parsed.data.query - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } + const resolved = await readPublicSharedFile({ + token, + fileId: parsed.data.query.documentId, + authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'), + }) const { file: doc } = resolved if (!doc.workspaceId) { @@ -129,8 +119,16 @@ export const GET = withRouteHandler( request, }) + response.headers.set('Cache-Control', 'private, no-store') return response } catch (error) { + if (error instanceof PublicShareAccessError) { + if (error.status === 404) return createErrorResponse(new FileNotFoundError('Not found')) + return NextResponse.json( + { error: error.message }, + { status: error.status, headers: { 'Cache-Control': 'private, no-store' } } + ) + } if (error instanceof FileNotFoundError) { return createErrorResponse(error) } diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 57a707c189b..e39e4585bc0 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -26,7 +26,7 @@ import { afterResponse } from '@/lib/core/utils/after-response' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { sendEmail } from '@/lib/messaging/email/mailer' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' export const dynamic = 'force-dynamic' @@ -116,7 +116,7 @@ export const POST = withRouteHandler( // all key off the same value (allow-list entries are stored lowercase). const email = normalizeEmail(parsed.data.body.email) - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } @@ -155,7 +155,7 @@ export const PUT = withRouteHandler( const { otp } = parsed.data.body const email = normalizeEmail(parsed.data.body.email) - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/files/public/[token]/route.test.ts b/apps/sim/app/api/files/public/[token]/route.test.ts index 2a996f664d4..4c4503bdc52 100644 --- a/apps/sim/app/api/files/public/[token]/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/route.test.ts @@ -1,3 +1,5 @@ +import { publicShare as publicShareTable, workspaceFiles } from '@sim/db/schema' +import { queueTableRows } from '@sim/testing/mocks/database.mock' import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -38,7 +40,17 @@ const postRequest = (password: string, token = 'tok_1') => }) const publicShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, + share: { + id: 'sh_1', + token: 'tok_1', + resourceId: 'wf_1', + workspaceId: 'ws-secret', + resourceType: 'file', + isActive: true, + authType: 'public', + password: null, + allowedEmails: [], + }, file: { id: 'wf_1', key: 'workspace/ws/secret-key.pdf', @@ -53,13 +65,15 @@ const publicShare = { const passwordShare = { ...publicShare, - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, + share: { ...publicShare.share, authType: 'password', password: 'enc:secret' }, } describe('GET /api/files/public/[token]', () => { beforeEach(() => { mockEnforceRateLimit.mockResolvedValue(null) // allow by default mockValidateDeploymentAuth.mockResolvedValue({ authorized: true }) // public by default + queueTableRows(publicShareTable, [{ id: 'sh_1' }]) + queueTableRows(workspaceFiles, [publicShare.file]) }) it('returns 429 when the per-IP rate limit is exceeded', async () => { diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index 5874d977b9b..02ba19a4bbd 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -11,8 +11,9 @@ import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { PublicShareAccessError, readPublicSharedFile } from '@/lib/public-shares/access' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' export const dynamic = 'force-dynamic' @@ -38,32 +39,33 @@ export const GET = withRouteHandler( if (!parsed.success) return parsed.response const { token } = parsed.data.params - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file, workspaceName, ownerName } = resolved - return NextResponse.json({ + const resolved = await readPublicSharedFile({ token, - name: file.originalName, - type: file.contentType, - size: getWorkspaceFileSize(file), - workspaceName, - ownerName, + fileId: parsed.data.query.fileId, + authorize: (share) => validateDeploymentAuth(requestId, share, request, undefined, 'file'), }) + + const { file, workspaceName, ownerName } = resolved + return NextResponse.json( + { + token, + name: file.originalName, + type: file.contentType, + size: getWorkspaceFileSize(file), + workspaceName, + ownerName, + ...(parsed.data.query.fileId + ? { version: file.updatedAt.getTime(), folderId: file.folderId } + : {}), + }, + { headers: { 'Cache-Control': 'private, no-store' } } + ) } catch (error) { + if (error instanceof PublicShareAccessError) + return NextResponse.json( + { error: error.message }, + { status: error.status, headers: { 'Cache-Control': 'private, no-store' } } + ) logger.error('Error fetching public file metadata:', error) return NextResponse.json( { error: getErrorMessage(error, 'Failed to fetch file') }, @@ -89,7 +91,7 @@ export const POST = withRouteHandler( const { token } = parsed.data.params const { password } = parsed.data.body - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts index bc94fdcd0b6..3019a8ccd0c 100644 --- a/apps/sim/app/api/files/public/[token]/sso/route.ts +++ b/apps/sim/app/api/files/public/[token]/sso/route.ts @@ -9,7 +9,7 @@ import { RateLimiter } from '@/lib/core/rate-limiter' import { isEmailAllowed } from '@/lib/core/security/deployment' import { generateRequestId, getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' @@ -66,7 +66,7 @@ export const POST = withRouteHandler( const { token } = parsed.data.params const email = normalizeEmail(parsed.data.body.email) - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved) { return NextResponse.json({ error: 'Not found' }, { status: 404 }) } diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/share/route.ts new file mode 100644 index 00000000000..fe60cb7c9a3 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/share/route.ts @@ -0,0 +1,42 @@ +import { + getFolderShareContract, + upsertFolderShareContract, +} from '@/lib/api/contracts/public-shares' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + getWorkspaceFileFolderShare, + updateWorkspaceFileFolderShare, +} from '@/lib/workspace-files/application/share-workspace-file-folder' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: getFolderShareContract, + auth: internalSessionAuth, + operation: fileOperations.readFolderShare, + rateLimit: internalRateLimits.none({ + reason: 'Same authenticated settings read as file sharing', + }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), + useCase: getWorkspaceFileFolderShare, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) + +export const PUT = defineInternalJsonRoute({ + contract: upsertFolderShareContract, + auth: internalSessionAuth, + operation: fileOperations.updateFolderShare, + rateLimit: internalRateLimits.user({ bucketName: 'workspace-folder-sharing' }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ workspaceId: params.id, folderId: params.folderId, ...body }), + useCase: updateWorkspaceFileFolderShare, + present: ({ share }) => ({ share }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/prepare-upload/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/prepare-upload/route.ts new file mode 100644 index 00000000000..58bad792680 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/prepare-upload/route.ts @@ -0,0 +1,25 @@ +import { prepareUploadFoldersContract } from '@/lib/api/contracts/workspace-file-folder-upload' +import { + defineInternalJsonRoute, + internalJsonPresenters, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { prepareUploadFoldersOperation } from '@/lib/workspace-files/application/prepare-upload-folders' + +export const POST = defineInternalJsonRoute({ + contract: prepareUploadFoldersContract, + auth: internalSessionAuth, + operation: fileOperations.prepareUploadFolders, + rateLimit: internalRateLimits.user({ + bucketName: 'file-folder-upload', + config: { maxTokens: 20, refillRate: 10, refillIntervalMs: 60_000 }, + }), + parseOptions: { maxBodyBytes: 1024 * 1024 }, + errorPolicy: internalFileErrorPolicies.default, + mapInput: ({ params, body }) => ({ ...body, workspaceId: params.id }), + useCase: prepareUploadFoldersOperation, + present: internalJsonPresenters.withSuccess, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/search/route.ts b/apps/sim/app/api/workspaces/[id]/files/search/route.ts new file mode 100644 index 00000000000..3d296ee4900 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/search/route.ts @@ -0,0 +1,41 @@ +import { searchWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-file-search' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } 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' + +export const GET = defineInternalJsonRoute({ + contract: searchWorkspaceFileContentContract, + auth: internalSessionAuth, + operation: fileOperations.searchContent, + rateLimit: internalRateLimits.user({ + bucketName: 'workspace-file-content-search', + config: { maxTokens: 60, refillRate: 60, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, + mapInput: ({ params, query }, { request }) => ({ + workspaceId: params.id, + query: query.query, + mode: 'exact' as const, + maxResults: query.maxResults, + folderPaths: query.folderPath === undefined ? undefined : [query.folderPath], + includeFileMetadata: true, + signal: request.signal, + }), + useCase: searchWorkspaceFileContent, + present: ({ results, count, truncated, complete, indexStatus, files }) => ({ + results, + count, + truncated, + complete, + indexStatus, + files: files ?? [], + }), +}) diff --git a/apps/sim/app/f/[token]/loading.tsx b/apps/sim/app/f/[token]/loading.tsx new file mode 100644 index 00000000000..a7f748d5356 --- /dev/null +++ b/apps/sim/app/f/[token]/loading.tsx @@ -0,0 +1,22 @@ +import { SimWordmark } from '@sim/emcn' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' +import { getBrandConfig } from '@/ee/whitelabeling' + +export default function SharedResourceLoading() { + const brand = getBrandConfig() + return ( +
+ +
+ {!brand.logoUrl && } + Shared files +
+
+ Loading… +
+
+ ) +} diff --git a/apps/sim/app/f/[token]/opengraph-image.tsx b/apps/sim/app/f/[token]/opengraph-image.tsx index 2d56c0e3b2f..b64f3c50957 100644 --- a/apps/sim/app/f/[token]/opengraph-image.tsx +++ b/apps/sim/app/f/[token]/opengraph-image.tsx @@ -1,5 +1,5 @@ import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' import { buildProvenance } from '@/app/f/[token]/utils' export const dynamic = 'force-dynamic' @@ -14,19 +14,19 @@ export const size = COVER_OG_SIZE */ export default async function Image({ params }: { params: Promise<{ token: string }> }) { const { token } = await params - const resolved = await resolveActiveShareByToken(token) + const resolved = await resolveActiveResourceShareByToken(token) if (!resolved || resolved.share.authType !== 'public') { return createCoverOgImage({ - title: 'Protected file', - subtitle: 'Authentication is required to view this file', + title: resolved?.kind === 'folder' ? 'Protected folder' : 'Protected file', + subtitle: `Authentication is required to view this ${resolved?.kind ?? 'file'}`, }) } - const { file, workspaceName, ownerName } = resolved + const { workspaceName, ownerName } = resolved return createCoverOgImage({ - title: file.originalName, + title: resolved.kind === 'folder' ? resolved.folder.name : resolved.file.originalName, subtitle: buildProvenance(workspaceName, ownerName) || 'Shared via Sim', }) } diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx index 2c59e81d057..936ec67e267 100644 --- a/apps/sim/app/f/[token]/page.tsx +++ b/apps/sim/app/f/[token]/page.tsx @@ -1,4 +1,4 @@ -import { cache } from 'react' +import { cache, Suspense } from 'react' import type { Metadata } from 'next' import { cookies } from 'next/headers' import { notFound } from 'next/navigation' @@ -8,19 +8,21 @@ import { isEmailAllowed, validateAuthToken, } from '@/lib/core/security/deployment' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { resolveActiveResourceShareByToken } from '@/lib/public-shares/share-manager' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import SharedResourceLoading from '@/app/f/[token]/loading' import { PublicFileAuth } from '@/app/f/[token]/public-file-auth' import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' import { PublicFileSSOAuth } from '@/app/f/[token]/public-file-sso-auth' import { PublicFileView } from '@/app/f/[token]/public-file-view' +import { PublicFolderView } from '@/app/f/[token]/public-folder-view' import { buildProvenance } from '@/app/f/[token]/utils' import { getBrandConfig } from '@/ee/whitelabeling' export const dynamic = 'force-dynamic' /** Deduped per-request so `generateMetadata` and the page share one DB resolve. */ -const resolveShare = cache(resolveActiveShareByToken) +const resolveShare = cache(resolveActiveResourceShareByToken) /** Shared links must never be indexed by search engines. */ const NOINDEX = { index: false, follow: false } as const @@ -44,12 +46,13 @@ export async function generateMetadata({ params }: PublicFilePageProps): Promise let title: string let description: string if (resolved.share.authType !== 'public') { - title = 'Shared file' - description = 'Authentication is required to view this file.' + title = resolved.kind === 'folder' ? 'Shared folder' : 'Shared file' + description = `Authentication is required to view this ${resolved.kind}.` } else { - title = resolved.file.originalName + title = resolved.kind === 'file' ? resolved.file.originalName : resolved.folder.name description = - buildProvenance(resolved.workspaceName, resolved.ownerName) || `Shared file · ${title}` + buildProvenance(resolved.workspaceName, resolved.ownerName) || + `Shared ${resolved.kind} · ${title}` } const brand = getBrandConfig() @@ -75,7 +78,7 @@ interface GateShare { * or `null` when the visitor may view the file. `password`/`email` use the * `file_auth_{shareId}` cookie; `sso` uses the global Sim session. */ -async function renderAuthGate(token: string, share: GateShare) { +async function renderAuthGate(token: string, share: GateShare, resourceType: 'file' | 'folder') { if (share.authType === 'public') return null if (share.authType === 'sso') { @@ -86,7 +89,7 @@ async function renderAuthGate(token: string, share: GateShare) { const authorized = Boolean( session?.user?.email && isEmailAllowed(session.user.email, allowedEmails) ) - return authorized ? null : + return authorized ? null : } const cookieStore = await cookies() @@ -94,9 +97,9 @@ async function renderAuthGate(token: string, share: GateShare) { if (await validateAuthToken({ token: cookieValue ?? '', resource: share })) return null return share.authType === 'email' ? ( - + ) : ( - + ) } @@ -108,11 +111,24 @@ export default async function PublicFilePage({ params }: PublicFilePageProps) { notFound() } - const { share, file, workspaceName, ownerName } = resolved + const { share, workspaceName, ownerName } = resolved - const gate = await renderAuthGate(token, share) + const gate = await renderAuthGate(token, share, resolved.kind) if (gate) return gate + if (resolved.kind === 'folder') { + return ( + }> + + + ) + } + const { file } = resolved return ( +
{ e.preventDefault() diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx index 8839f44f4a7..483548b9a9a 100644 --- a/apps/sim/app/f/[token]/public-file-email-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx @@ -12,6 +12,7 @@ import { usePublicFileOtpRequest, usePublicFileOtpVerify } from '@/hooks/queries interface PublicFileEmailAuthProps { token: string + resourceType?: 'file' | 'folder' } /** @@ -19,7 +20,7 @@ interface PublicFileEmailAuthProps { * send a 6-digit code, verify it. On success the server sets the * `file_auth_{shareId}` cookie and the page re-renders the viewer. */ -export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { +export function PublicFileEmailAuth({ token, resourceType = 'file' }: PublicFileEmailAuthProps) { const router = useRouter() const requestOtp = usePublicFileOtpRequest(token) const verifyOtp = usePublicFileOtpVerify(token) @@ -78,7 +79,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) { return ( { diff --git a/apps/sim/app/f/[token]/public-file-sso-auth.tsx b/apps/sim/app/f/[token]/public-file-sso-auth.tsx index 3e73581f715..c708b42fd01 100644 --- a/apps/sim/app/f/[token]/public-file-sso-auth.tsx +++ b/apps/sim/app/f/[token]/public-file-sso-auth.tsx @@ -13,6 +13,7 @@ import { PublicFileAuthShell } from '@/app/f/[token]/public-file-auth-shell' interface PublicFileSSOAuthProps { token: string + resourceType?: 'file' | 'folder' } /** @@ -20,7 +21,7 @@ interface PublicFileSSOAuthProps { * then hand off to the global `/sso` flow with this share as the callback. After * sign-in the page gate authorizes via the Sim session. */ -export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) { +export function PublicFileSSOAuth({ token, resourceType = 'file' }: PublicFileSSOAuthProps) { const router = useRouter() const [email, setEmail] = useState('') const [error, setError] = useState(null) @@ -40,16 +41,16 @@ export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) { body: { email: normalizedEmail }, }) if (!eligible) { - setError('Email not authorized for this file.') + setError(`Email not authorized for this ${resourceType}.`) setIsLoading(false) return } - const callbackUrl = `/f/${token}` + const callbackUrl = `/f/${token}${window.location.search}` router.push( `/sso?email=${encodeURIComponent(normalizedEmail)}&callbackUrl=${encodeURIComponent(callbackUrl)}` ) } catch (err) { - setError(getErrorMessage(err, 'Email not authorized for this file.')) + setError(getErrorMessage(err, `Email not authorized for this ${resourceType}.`)) setIsLoading(false) } } @@ -57,7 +58,7 @@ export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) { return ( { diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 4b57b43f56d..2bdc17b407d 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -36,35 +36,6 @@ export function PublicFileView({ const brand = useBrandConfig() const provenance = buildProvenance(workspaceName, ownerName) - // The public viewer reuses the in-app FileViewer; the content source seam swaps - // the auth-gated workspace serve URL for the token-scoped public endpoint, and a - // synthetic record carries the metadata the renderers/query keys need. `key` and - // `updatedAt` fold in the content version so the React Query caches (keyed on the - // storage key + `updatedAt`) refetch when the shared file changes — even when its - // size is unchanged. - // Embedded images route through the token-scoped cascade endpoint, which serves them only when the - // shared document actually references them and they live in its workspace. - const source = useMemo( - () => createPublicFileContentSource(token, contentUrl), - [token, contentUrl] - ) - const file = useMemo( - () => ({ - id: token, - workspaceId: token, - name, - key: `${token}@${version}`, - path: contentUrl, - size, - type, - uploadedBy: '', - folderId: null, - uploadedAt: new Date(version), - updatedAt: new Date(version), - }), - [token, name, type, size, version, contentUrl] - ) - return (
@@ -108,15 +79,59 @@ export function PublicFileView({
- +
) } + +interface PublicFilePreviewProps { + token: string + fileId?: string + name: string + type: string + size: number + version: number +} + +/** The same read-only renderer serves direct file shares and children of a folder capability. */ +export function PublicFilePreview({ + token, + fileId, + name, + type, + size, + version, +}: PublicFilePreviewProps) { + const contentUrl = `/api/files/public/${token}/content${fileId ? `?fileId=${encodeURIComponent(fileId)}` : ''}` + const source = useMemo( + () => createPublicFileContentSource(token, contentUrl, fileId), + [token, contentUrl, fileId] + ) + const file = useMemo( + () => ({ + id: fileId ?? token, + workspaceId: token, + name, + key: `${token}:${fileId ?? ''}@${version}`, + path: contentUrl, + size, + type, + uploadedBy: '', + folderId: null, + uploadedAt: new Date(version), + updatedAt: new Date(version), + }), + [fileId, token, name, type, size, version, contentUrl] + ) + return ( + + ) +} diff --git a/apps/sim/app/f/[token]/public-folder-view.tsx b/apps/sim/app/f/[token]/public-folder-view.tsx new file mode 100644 index 00000000000..d3dee4e897e --- /dev/null +++ b/apps/sim/app/f/[token]/public-folder-view.tsx @@ -0,0 +1,232 @@ +'use client' + +import { useRef } from 'react' +import { + Chip, + cn, + OverflowText, + SimWordmark, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' +import { ChevronRight, Download, File, Folder } from '@sim/emcn/icons' +import Link from 'next/link' +import { useQueryStates } from 'nuqs' +import { SITE_URL } from '@/lib/core/utils/urls' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' +import { PublicFilePreview } from '@/app/f/[token]/public-file-view' +import { publicFolderParsers, publicFolderUrlKeys } from '@/app/f/[token]/search-params' +import { buildProvenance } from '@/app/f/[token]/utils' +import { useBrandConfig } from '@/ee/whitelabeling' +import { usePublicFolder, usePublicSharedFile } from '@/hooks/queries/public-shares' + +interface PublicFolderViewProps { + token: string + name: string + workspaceName: string | null + ownerName: string | null +} + +/** A public capability browses the live folder tree, with all file reads bound to its token. */ +export function PublicFolderView({ token, name, workspaceName, ownerName }: PublicFolderViewProps) { + const [{ folderId, fileId, cursor }, setLocation] = useQueryStates( + publicFolderParsers, + publicFolderUrlKeys + ) + const file = usePublicSharedFile(token, fileId) + const currentFolderId = fileId ? (file.data?.folderId ?? folderId) : folderId + const listing = usePublicFolder( + token, + currentFolderId, + fileId ? null : cursor, + !fileId || file.isSuccess + ) + const brand = useBrandConfig() + const provenance = buildProvenance(workspaceName, ownerName) + const error = fileId ? (file.error ?? listing.error) : listing.error + const loading = + listing.isPending || + listing.isFetching || + Boolean(fileId && (file.isPending || file.isFetching)) + const page = !loading && !error ? listing.data : undefined + const currentFile = !loading && !error ? file.data : undefined + const entries = page?.entries ?? [] + const breadcrumbs = page?.breadcrumbs ?? [] + const scrollRef = useRef(null) + const edges = useScrollEdges(scrollRef, { enabled: !fileId && Boolean(page) }) + + return ( +
+ +
+
+ {!brand.logoUrl && ( + <> + + + +
+ + )} +
+ + {provenance && ( + + )} +
+
+ {fileId && currentFile && ( + { + const anchor = document.createElement('a') + anchor.href = `/api/files/public/${token}/content?fileId=${encodeURIComponent(fileId)}` + anchor.download = currentFile.name + anchor.click() + }} + > + Download + + )} +
+
+ {error ? ( +
+

This shared content is unavailable.

+ { + if (fileId && file.isError) void file.refetch() + else void listing.refetch() + }} + > + Try again + +
+ ) : loading ? ( +
+ Loading… +
+ ) : fileId && currentFile ? ( + + ) : ( +
+ + + + + + + + + {entries.map((entry) => ( + + + + + ))} + +
NameSize
+ + setLocation( + entry.kind === 'folder' + ? { folderId: entry.id, fileId: null, cursor: null } + : { + folderId: page?.folder.id ?? folderId, + fileId: entry.id, + cursor: null, + } + ) + } + > + {entry.name} + + + {entry.kind === 'file' ? formatFileSize(entry.size) : '—'} +
+ {entries.length === 0 && ( +

+ This folder is empty. +

+ )} + {(page?.previousCursor || page?.nextCursor) && ( + + )} +
+ )} +
+
+ ) +} diff --git a/apps/sim/app/f/[token]/search-params.ts b/apps/sim/app/f/[token]/search-params.ts new file mode 100644 index 00000000000..3b4a0717779 --- /dev/null +++ b/apps/sim/app/f/[token]/search-params.ts @@ -0,0 +1,14 @@ +import { parseAsString } from 'nuqs/server' + +/** Null selects the shared root, its listing, and the first page respectively. */ +export const publicFolderParsers = { + folderId: parseAsString, + fileId: parseAsString, + cursor: parseAsString, +} as const + +export const publicFolderUrlKeys = { + history: 'push', + clearOnDefault: true, + urlKeys: { folderId: 'folder-id', fileId: 'file-id' }, +} as const diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts index 1ff57a3becb..5e438d42807 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/folder-breadcrumbs.ts @@ -1,4 +1,5 @@ import type { ElementType } from 'react' +import { Folder } from '@sim/emcn' import { folderAncestorChain } from '@/lib/folders/tree' import type { BreadcrumbEditing, @@ -44,6 +45,7 @@ interface FolderBreadcrumbItemsBase { breadcrumbs: BreadcrumbFolder[] /** Called with the folder to open, or `null` for the workspace root. */ onNavigate: (folderId: string | null) => void + childFolders?: ReadonlyMap } /** A list page: the deepest folder is where you are, so its crumb carries the rename and menu. */ @@ -90,15 +92,32 @@ export function folderBreadcrumbItems(options: FolderBreadcrumbItemsOptions): Br const { rootLabel, rootIcon, breadcrumbs, onNavigate } = options const trailing = options.trailing ?? NO_TRAILING_CRUMBS + const navigationItems = (folderId: string | null) => + options.childFolders?.get(folderId)?.map((folder) => ({ + id: folder.id, + label: folder.name, + icon: Folder, + onClick: () => onNavigate(folder.id), + })) ?? (options.childFolders ? [] : undefined) + const items: BreadcrumbItem[] = [ - { label: rootLabel, icon: rootIcon, folderId: null, onClick: () => onNavigate(null) }, + { + id: 'root', + label: rootLabel, + icon: rootIcon, + folderId: null, + onClick: () => onNavigate(null), + navigationItems: navigationItems(null), + }, ] breadcrumbs.forEach((folder, index) => { /** Where you already are — and on a detail page that is a trailing crumb, not a folder. */ const isOpenFolder = trailing.length === 0 && index === breadcrumbs.length - 1 items.push({ + id: folder.id, label: folder.name, + navigationItems: navigationItems(folder.id), folderId: folder.id, onClick: isOpenFolder ? undefined : () => onNavigate(folder.id), dropdownItems: diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts index 61c981dc349..5fea77778d2 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-folder-row-drag-drop.ts @@ -451,6 +451,7 @@ export function useFolderRowDragDrop({ * one files the drag there directly. */ breadcrumb: { + isDragging: draggedRowIds.size > 0, activeIndex: activeDropTarget?.kind === 'crumb' ? activeDropTarget.index : null, onDragOver: (e: DragEvent, folderId: string | null, index: number) => { if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 301cc2e7dba..fbce160646d 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -14,6 +14,8 @@ import { import { Chip, ChipChevronDown, + ChipCombobox, + type ComboboxOption, chipContentIconClass, chipDropTargetSurfaceClass, chipGeometryClass, @@ -37,12 +39,12 @@ import { useIsOverflowing, } from '@sim/emcn' import { ArrowUpLeft } from '@sim/emcn/icons' -import { createPortal } from 'react-dom' import { HEADER_ACTION_CLUSTER, TITLE_BAR_LANE_PT } from '@/components/page-header-bar' import { orderHeaderActions, SettingsActionChip } from '@/components/settings/settings-header' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' export interface DropdownOption { + id?: string label: string icon?: React.ElementType onClick: () => void @@ -65,6 +67,8 @@ export interface BreadcrumbEditing { } export interface BreadcrumbItem { + id?: string + navigationItems?: DropdownOption[] label: string /** * The folder this crumb navigates to (`null` is the workspace root). Supplying it makes the @@ -113,6 +117,8 @@ export interface ResourceAction { * on one files the drag there — the counterpart to spring-loading, which only ever goes deeper. */ export interface BreadcrumbDropConfig { + /** Expands collapsed ancestors while rows are being moved. */ + isDragging?: boolean /** Index of the crumb currently under the drag, or `null`. Indexed because `null` is a folder. */ activeIndex: number | null onDragOver: (e: DragEvent, folderId: string | null, index: number) => void @@ -145,17 +151,16 @@ export const ResourceHeader = memo(function ResourceHeader({ aside, breadcrumbDrop, }: ResourceHeaderProps) { - const headerRef = useRef(null) - /** - * Breadcrumb mode is reserved for nested pages (length > 1). A single-crumb - * "breadcrumb" is just the current page, so it falls through to the static - * title below — keeping the top-left non-interactive and hover-free, - * identical to a title-only page (e.g. the Files root matches the Tables root). - */ + /** Single-crumb roots retain their static title and may offer child-folder navigation. */ const hasBreadcrumbs = breadcrumbs != null && breadcrumbs.length > 1 const rootCrumb = breadcrumbs?.length === 1 ? breadcrumbs[0] : undefined const TitleIcon = Icon ?? rootCrumb?.icon const titleLabel = title ?? rootCrumb?.label + const collapsePath = + breadcrumbs != null && + breadcrumbs.length > 4 && + !breadcrumbDrop?.isDragging && + breadcrumbs.some((item) => item.navigationItems !== undefined) const terminalBreadcrumbIndex = hasBreadcrumbs && breadcrumbs[breadcrumbs.length - 1].terminal ? breadcrumbs.length - 1 : -1 const currentResourceIndex = @@ -167,7 +172,6 @@ export const ResourceHeader = memo(function ResourceHeader({ return (
{hasBreadcrumbs ? ( breadcrumbs.map((crumb, i) => { + if (collapsePath && i > 0 && i < breadcrumbs.length - 2) { + if (i !== 1) return null + return ( + + + / + + + item.onClick + ? [ + { + id: item.id, + label: item.label, + onClick: item.onClick, + }, + ] + : [] + )} + className='shrink-0' + /> + + ) + } const segmentClassName = getBreadcrumbSegmentClassName( i, breadcrumbs.length, @@ -208,31 +237,41 @@ export const ResourceHeader = memo(function ResourceHeader({ : undefined return ( - + {i > 0 && ( / )} - {showLocationPopover ? ( - - ) : ( - - )} +
+ {showLocationPopover ? ( + + ) : ( + + )} + {crumb.navigationItems !== undefined && !crumb.editing?.isEditing && ( + + )} +
) }) @@ -246,14 +285,23 @@ export const ResourceHeader = memo(function ResourceHeader({ * layout (the JS-driven `--sidebar-width` settling) and the title * CSS-truncates to "T…" while the `shrink-0` actions hold width. */ - - {TitleIcon && } - {titleLabel && ( - - {titleLabel} - + <> + + {TitleIcon && } + {titleLabel && ( + + {titleLabel} + + )} + + {rootCrumb?.navigationItems !== undefined && ( + )} - + )}
{(aside || (actions && actions.length > 0)) && ( @@ -382,7 +430,11 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({ {dropdownItems.map((item) => { const ItemIcon = item.icon return ( - + {ItemIcon && } {item.label} @@ -438,7 +490,6 @@ interface BreadcrumbLocationPopoverProps { icon: React.ElementType breadcrumbs: BreadcrumbItem[] className?: string - veilBoundaryRef: React.RefObject drag?: BreadcrumbSegmentProps['drag'] } @@ -454,7 +505,6 @@ function BreadcrumbLocationPopover({ icon: Icon, breadcrumbs, className, - veilBoundaryRef, drag, }: BreadcrumbLocationPopoverProps) { const [open, setOpen] = useState(false) @@ -474,7 +524,7 @@ function BreadcrumbLocationPopover({ * click-to-navigate: a stationary click fires no enter event, so once * {@link navigateAndClose} sets `open` false nothing re-opens it before the * route swaps. (A move-driven open would re-fire under the resting cursor and - * flash the popover/veil back in mid-navigation.) + * reopen the popover mid-navigation.) */ const openPopover = () => { cancelScheduledClose() @@ -489,11 +539,6 @@ function BreadcrumbLocationPopover({ }, POPOVER_CLOSE_DELAY_MS) } - /** - * Closes the popover up front, then runs the crumb's handler. Closing first - * lets the veil fade and the popover play its exit animation instead of - * snapping away when navigation unmounts the header. - */ const navigateAndClose = (onClick?: () => void) => { if (!onClick) return cancelScheduledClose() @@ -509,7 +554,6 @@ function BreadcrumbLocationPopover({ return ( <> -