diff --git a/apps/sim/app/_shell/paste-admission-guard.tsx b/apps/sim/app/_shell/paste-admission-guard.tsx index 2555c1d698c..1fd970215db 100644 --- a/apps/sim/app/_shell/paste-admission-guard.tsx +++ b/apps/sim/app/_shell/paste-admission-guard.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef } from 'react' import { useToast } from '@sim/emcn' import { assessTextPaste, formatPasteLimit, PASTE_LIMITS } from '@sim/utils/paste' import { readSelectionContextFromClipboard } from '@/lib/mothership/chat/selection-clipboard' +import { FileOperationOwner } from '@/lib/mothership/generated/file-owner' const EDITABLE_TARGET_SELECTOR = 'input:not([type="file"]):not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]):not([type="hidden"]), textarea, [contenteditable]:not([contenteditable="false"]), .monaco-editor, .xterm' @@ -23,6 +24,27 @@ function clipboardHasImageFile(data: DataTransfer | null): boolean { ) } +function selectionOwnerHints(element: Element | null): FileOperationOwner[] { + const encoded = element?.getAttribute('data-paste-selection-owners') + if (encoded !== null && encoded !== undefined) { + try { + const parsed: unknown = JSON.parse(encoded) + if (!Array.isArray(parsed)) return [] + const owners: FileOperationOwner[] = [] + for (const candidate of parsed) { + const owner = FileOperationOwner.safeParse(candidate) + if (!owner.success) return [] + owners.push(owner.data) + } + return owners + } catch { + return [] + } + } + const workspaceId = element?.getAttribute('data-paste-selection-context') + return workspaceId ? [{ entityType: 'workspace', entityId: workspaceId }] : [] +} + /** * Last-resort admission for every editable workspace surface. Specialized editors publish their * downstream ceiling on an ancestor with `data-paste-max-bytes`; controls without one inherit a @@ -42,13 +64,15 @@ export function PasteAdmissionGuard() { return } - const acceptsSelectionContext = event.target.closest('[data-paste-selection-context]') - const destinationWorkspaceId = acceptsSelectionContext?.getAttribute( - 'data-paste-selection-context' + const acceptsSelectionContext = event.target.closest( + '[data-paste-selection-owners], [data-paste-selection-context]' ) if ( - destinationWorkspaceId && - readSelectionContextFromClipboard(event.clipboardData, destinationWorkspaceId) + acceptsSelectionContext && + readSelectionContextFromClipboard( + event.clipboardData, + selectionOwnerHints(acceptsSelectionContext) + ) ) { return } diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts index 565b14af083..6834923367d 100644 --- a/apps/sim/app/api/copilot/chat/queries.ts +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -20,6 +20,7 @@ import { createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/mothership/request/http' +import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation' import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, @@ -49,7 +50,11 @@ function transformChat(chat: { effort: chat.effort ?? null, ...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}), ...('resources' in chat - ? { resources: Array.isArray(chat.resources) ? chat.resources : [] } + ? { + resources: Array.isArray(chat.resources) + ? chat.resources.map(presentChatResourceForBrowser) + : [], + } : {}), createdAt: chat.createdAt, updatedAt: chat.updatedAt, diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 1cb4f71eea8..c05e09cdd80 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -5,12 +5,13 @@ import { } from '@/lib/api/contracts/copilot' import { defineInternalJsonRoute, - internalJsonPresenters, internalOrchestrationErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { changeChatResources } from '@/lib/mothership/chat/application/change-resources' +import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation' +import type { MothershipResource } from '@/lib/mothership/resources/types' const policy = { auth: internalSessionAuth, @@ -20,7 +21,10 @@ const policy = { }), errorPolicy: internalOrchestrationErrorPolicy, useCase: changeChatResources, - present: internalJsonPresenters.withSuccess, + present: ({ resources }: { resources: MothershipResource[] }) => ({ + success: true as const, + resources: resources.map(presentChatResourceForBrowser), + }), } export const POST = defineInternalJsonRoute({ @@ -53,7 +57,14 @@ export const DELETE = defineInternalJsonRoute({ chatId: body.chatId, change: { kind: 'remove' as const, - resources: [{ type: body.resourceType, id: body.resourceId, workspaceId: body.workspaceId }], + resources: [ + { + type: body.resourceType, + id: body.resourceId, + workspaceId: body.workspaceId, + owner: body.owner, + }, + ], }, }), }) diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 6be0e4ada77..3c5b3339fd6 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -28,6 +28,7 @@ import { createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/mothership/request/http' +import { presentChatResourceForBrowser } from '@/lib/mothership/resources/presentation' import { captureServerEvent } from '@/lib/posthog/server' const logger = createLogger('MothershipChatAPI') @@ -93,7 +94,9 @@ export const GET = withRouteHandler( mode: chat.mode, messages: effectiveMessages, activeStreamId: liveStreamId, - resources: Array.isArray(chat.resources) ? chat.resources : [], + resources: Array.isArray(chat.resources) + ? chat.resources.map(presentChatResourceForBrowser) + : [], effort: chat.effort, createdAt: chat.createdAt, updatedAt: chat.updatedAt, diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index 059c28598f2..b035c9fbfdf 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -48,6 +48,7 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock) vi.mock('@/hooks/queries/workspace', () => ({ useWorkspacesQuery: () => ({ data: mocks.workspaces }), + useOrderedWorkspacesQuery: () => ({ data: mocks.workspaces }), })) vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }), @@ -234,6 +235,8 @@ async function render( dashboards: false, 'mothership-model-selector': mocks.advanced, 'mothership-plan-mode': mocks.plan, + projects: false, + 'project-files': false, }} > @@ -311,6 +314,8 @@ it.each([ dashboards: false, 'mothership-model-selector': false, 'mothership-plan-mode': planEnabled, + projects: false, + 'project-files': false, }} > @@ -405,6 +410,8 @@ it('keeps restored queued skills scoped when replacing a draft', async () => { dashboards: false, 'mothership-model-selector': mocks.advanced, 'mothership-plan-mode': mocks.plan, + projects: false, + 'project-files': false, }} > diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx index aa3b1c5a65a..a30befa6973 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.test.tsx @@ -1,6 +1,7 @@ /** @vitest-environment jsdom */ import { act, type ComponentProps, type ReactNode } from 'react' +import { toast } from '@sim/emcn' import { authClientMock, authClientMockFns } from '@sim/testing/mocks/auth-client.mock' import { createMockDeploymentShape, @@ -9,7 +10,6 @@ import { } from '@sim/testing/mocks/deployment-shape.mock' import { integrationMatcherMock } from '@sim/testing/mocks/integration-matcher.mock' import { kbConnectorsQueriesMock } from '@sim/testing/mocks/kb-connectors-queries.mock' -import { nextNavigationMock } from '@sim/testing/mocks/next-navigation.mock' import { organizationProviderMock, organizationProviderMockFns, @@ -24,6 +24,8 @@ import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/st const mocks = vi.hoisted(() => ({ plan: false, + projects: true, + projectFiles: true, resourcePanel: vi.fn(), chat: vi.fn(), composer: vi.fn(), @@ -40,11 +42,21 @@ const mocks = vi.hoisted(() => ({ activeResource: null as string | null, })) vi.mock('@/app/workspace/[workspaceId]/providers/feature-flags-provider', () => ({ - useFeatureFlag: (name: string) => (name === 'mothership-plan-mode' ? mocks.plan : false), + useFeatureFlag: (name: string) => + name === 'mothership-plan-mode' + ? mocks.plan + : name === 'projects' + ? mocks.projects + : name === 'project-files' + ? mocks.projectFiles + : false, })) vi.mock('@/lib/core/config/deployment-shape', () => deploymentShapeMock) vi.mock('@/blocks/integration-matcher', () => integrationMatcherMock) -vi.mock('next/navigation', () => nextNavigationMock) +vi.mock( + 'next/navigation', + async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock +) vi.mock('@tanstack/react-query', () => reactQueryMock) vi.mock('@/app/workspace/[workspaceId]/home/hooks/use-resource-panel', () => ({ useResourcePanelController: () => ({ @@ -103,6 +115,8 @@ let root: Root let container: HTMLDivElement beforeEach(() => { useMothershipDraftsStore.setState({ drafts: {} }) + mocks.projects = true + mocks.projectFiles = true mocks.plan = false mocks.activeResource = null mockSession.mockReturnValue({ data: { user: { id: 'reader' } } }) @@ -224,3 +238,36 @@ it('isolates saved drafts by user, organization, and conversation', async () => await act(async () => renderHome()) expect(composerProps().value).toBe('') }) + +it.each([ + [false, true], + [true, false], + [false, false], +])( + 'does not look up saved Project references with flags projects=%s, files=%s', + async (projects, projectFiles) => { + mocks.projects = projects + mocks.projectFiles = projectFiles + vi.spyOn(toast, 'error').mockReturnValue('lookup-error') + const requests: unknown[] = [] + const resources: unknown[] = [] + vi.stubGlobal('fetch', async (input: unknown) => { + requests.push(input) + throw new Error('Project lookup is disabled') + }) + mocks.addResource.mockImplementation((resource: unknown) => resources.push(resource)) + await act(async () => renderHome()) + const selectResource = mocks.renderer.mock.lastCall?.[0].onWorkspaceResourceSelect + if (!selectResource) throw new Error('Chat resource selection is unavailable') + await act(async () => + selectResource({ + type: 'file', + id: 'file-a', + title: 'Saved Project file', + owner: { entityType: 'project', entityId: 'project-a' }, + }) + ) + expect(requests).toEqual([]) + expect(resources).toEqual([]) + } +) diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index f0dc31e6986..f55eb529fae 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -7,6 +7,7 @@ import Link from 'next/link' import { useQueryStates } from 'nuqs' import { requestJson } from '@/lib/api/client/request' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' +import { getProjectContract } from '@/lib/api/contracts/projects' import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces' import { useSession } from '@/lib/auth/auth-client' import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' @@ -47,7 +48,8 @@ import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature- import { useFileAttachments } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments' import { mentionifyIntegrations } from '@/blocks/integration-matcher' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' -import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/workspace-files' +import { getProjectFileQueryOptions } from '@/hooks/queries/project-files' +import { getWorkspaceFilesQueryOptions } from '@/hooks/queries/utils/workspace-file-query' import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store' import type { ChatContext } from '@/stores/panel' @@ -95,6 +97,8 @@ function OrganizationHomeContent({ const rememberMode = useOrganizationChatModeStore((state) => state.setMode) const [selectedMode, setSelectedMode] = useState(null) const planEnabled = useFeatureFlag('mothership-plan-mode') + const projectsEnabled = useFeatureFlag('projects') + const projectFilesEnabled = useFeatureFlag('project-files') const requestMode = selectedMode ?? (urlSearchLevel && searchAccess.memberScoped && !chatId ? 'assistant' : undefined) ?? @@ -171,11 +175,30 @@ function OrganizationHomeContent({ ]) const selectResource = useCallback( async (ref: WorkspaceResourceRef) => { - if (!ref.workspaceId) { - toast.error('This resource has no workspace address.') - return - } try { + if (ref.type === 'file' && ref.owner?.entityType === 'project') { + if (!projectsEnabled || !projectFilesEnabled) return + if (!ref.id) throw new Error('Missing Project file identity') + await requestJson(getProjectContract, { + params: { id: ref.owner.entityId }, + query: { organizationId: organization.id }, + }) + const result = await queryClient.fetchQuery({ + ...getProjectFileQueryOptions(ref.owner.entityId, ref.id), + staleTime: 0, + }) + addResource({ + type: 'file', + id: result.file.id, + title: result.file.name, + owner: result.file.owner, + }) + return + } + if (!ref.workspaceId) { + toast.error('This resource has no workspace address.') + return + } const host = await requestJson(getWorkspaceHostContextContract, { params: { id: ref.workspaceId }, }) @@ -197,7 +220,7 @@ function OrganizationHomeContent({ toast.error(`Couldn't open "${ref.title}". Check your access and try again.`) } }, - [queryClient, addResource, organization.id] + [queryClient, addResource, organization.id, projectsEnabled, projectFilesEnabled] ) const files = useFileAttachments({ userId: session?.user?.id, diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 78e1ace7284..ce9143ed467 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -4,10 +4,12 @@ import { redirect } from 'next/navigation' import { SettingsNavigationProvider } from '@/components/settings/settings-navigation-provider' import { getSession } from '@/lib/auth' import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { isProjectFileApiEnabled } from '@/lib/projects/rollout.server' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied' @@ -55,7 +57,14 @@ export default async function OrganizationLayout({ if (!context.mothershipAvailable && !context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) - const [, modelSelectorEnabled, planModeEnabled, dashboardsEnabled] = await Promise.all([ + const [ + , + modelSelectorEnabled, + planModeEnabled, + dashboardsEnabled, + projectsEnabled, + projectFilesEnabled, + ] = await Promise.all([ prefetchOrganizationSidebar( queryClient, organizationId, @@ -65,6 +74,8 @@ export default async function OrganizationLayout({ isMothershipModelSelectorEnabled(), isPlanModeEnabled(), isDashboardsEnabled(organizationId), + isFeatureEnabled('projects'), + isProjectFileApiEnabled(), ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' @@ -72,6 +83,8 @@ export default async function OrganizationLayout({ +} + +/** Resolves a durable Project link into an environment the current viewer can access. */ +export default async function ProjectFilePage({ params }: ProjectFilePageProps) { + if (!(await isProjectFileApiEnabled())) notFound() + const { projectId, fileId } = await params + if (!(await getSession())?.user) { + const destination = `/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(fileId)}` + redirect(`/login?callbackUrl=${encodeURIComponent(destination)}`) + } + const principal = await internalSessionAuth.authenticate() + let workspaceId: string | undefined + try { + const [context] = await Promise.all([ + getProject.execute({ principal, input: { projectId } }), + getProjectFileMetadata.execute({ principal, input: { projectId, fileId } }), + ]) + workspaceId = context.project.environments[0]?.id + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code === 'not_found' || classified?.code === 'forbidden') notFound() + throw error + } + if (!workspaceId) notFound() + redirect( + `/workspace/${encodeURIComponent(workspaceId)}/files/${encodeURIComponent(fileId)}?owner=project&projectId=${encodeURIComponent(projectId)}` + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts index 227c17308fa..474158d5191 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/drag-payload.ts @@ -1,3 +1,6 @@ +import { isRecordLike } from '@sim/utils/object' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + /** * The row ids a drag carries, written to and read from `dataTransfer` as JSON under a * private MIME type. @@ -14,22 +17,40 @@ export function writeRowDragPayload( dataTransfer: DataTransfer, mime: string, - rowIds: string[] + rowIds: string[], + owner?: EditableFileOwner ): void { - dataTransfer.setData(mime, JSON.stringify(rowIds)) + dataTransfer.setData(mime, JSON.stringify(owner ? { owner, rowIds } : rowIds)) dataTransfer.setData('text/plain', rowIds.join(',')) } /** * Reads the row ids back, returning `null` when the payload is absent (a foreign drag) or - * malformed (another writer on the same MIME) rather than throwing mid-drop. Callers fall back - * to their in-memory source for drags that never round-tripped through `dataTransfer`. + * malformed rather than throwing mid-drop. Owner-scoped readers require an exact pair and + * must not replace a rejected payload with an in-memory source. */ -export function readRowDragPayload(dataTransfer: DataTransfer, mime: string): string[] | null { +export function readRowDragPayload( + dataTransfer: DataTransfer, + mime: string, + owner?: EditableFileOwner +): string[] | null { const raw = dataTransfer.getData(mime) if (!raw) return null try { const parsed: unknown = JSON.parse(raw) + if (owner) { + if ( + !isRecordLike(parsed) || + !isRecordLike(parsed.owner) || + parsed.owner.entityType !== owner.entityType || + parsed.owner.entityId !== owner.entityId || + !Array.isArray(parsed.rowIds) || + !parsed.rowIds.every((id): id is string => typeof id === 'string' && id.length > 0) + ) { + return null + } + return parsed.rowIds.length > 0 ? parsed.rowIds : null + } if (!Array.isArray(parsed)) return null const rowIds = parsed.filter( (value): value is string => typeof value === 'string' && value.length > 0 diff --git a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts index a28ca2057a5..b12ed726b6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts +++ b/apps/sim/app/workspace/[workspaceId]/components/folders/use-drag-teardown.ts @@ -47,12 +47,13 @@ export function useDragTeardown(teardown: () => void): void { teardownRef.current() } - window.addEventListener('dragover', markDragging) + // Folder rows stop propagation after handling their drop target. + window.addEventListener('dragover', markDragging, true) window.addEventListener('dragend', endDrag) window.addEventListener('drop', endDrag) window.addEventListener('pointermove', endDrag) return () => { - window.removeEventListener('dragover', markDragging) + window.removeEventListener('dragover', markDragging, true) window.removeEventListener('dragend', endDrag) window.removeEventListener('drop', endDrag) window.removeEventListener('pointermove', endDrag) 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..7031038dff0 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 @@ -1,11 +1,16 @@ 'use client' import { type DragEvent, useCallback, useMemo, useRef, useState } from 'react' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { readRowDragPayload, writeRowDragPayload, } from '@/app/workspace/[workspaceId]/components/folders/drag-payload' -import { parseFolderedRowId } from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' +import { + folderRowId, + parseFolderedRowId, + splitFolderedRowIds, +} from '@/app/workspace/[workspaceId]/components/folders/folder-row-id' import { useDragTeardown } from '@/app/workspace/[workspaceId]/components/folders/use-drag-teardown' import { useRowDragGhost } from '@/app/workspace/[workspaceId]/components/folders/use-row-drag-ghost' import type { SpringOpenOptions } from '@/app/workspace/[workspaceId]/components/folders/use-spring-loaded-folder' @@ -119,6 +124,7 @@ export interface UseFolderRowDragDropOptions { * never mistaken for one of these rows — see {@link writeRowDragPayload}. */ dragMime: string + owner?: EditableFileOwner /** Drag and drop are edits; a reader gets neither draggable rows nor drop targets. */ canEdit: boolean /** Row currently being renamed inline, which must stay editable rather than draggable. */ @@ -203,6 +209,7 @@ export interface UseFolderRowDragDropOptions { */ export function useFolderRowDragDrop({ dragMime, + owner, canEdit, editingRowId, descendantsByFolderId, @@ -224,6 +231,8 @@ export function useFolderRowDragDrop({ * synchronously. */ const draggedRowIdsRef = useRef([]) + const sourceParentsRef = useRef | null>(null) + sourceParentsRef.current ??= new Map() const optionsRef = useRef({ descendantsByFolderId, @@ -259,6 +268,7 @@ export function useFolderRowDragDrop({ springNav.end() dragGhost.remove() draggedRowIdsRef.current = [] + sourceParentsRef.current?.clear() setDraggedRowIds(EMPTY_ROW_IDS) setActiveDropTarget(null) }, [dragGhost, springNav]) @@ -278,7 +288,16 @@ export function useFolderRowDragDrop({ */ const resolveMoveToFolder = useCallback( (targetFolderId: string | null, sourceRowIds: string[]): FolderedRowMove | null => { - const { descendantsByFolderId, getFolderParentId, getResourceFolderId } = optionsRef.current + const options = optionsRef.current + const { descendantsByFolderId } = options + const getFolderParentId = (id: string) => + options.getFolderParentId(id) !== undefined + ? options.getFolderParentId(id) + : sourceParentsRef.current?.get(folderRowId(id)) + const getResourceFolderId = (id: string) => + options.getResourceFolderId(id) !== undefined + ? options.getResourceFolderId(id) + : sourceParentsRef.current?.get(id) const folderIds: string[] = [] const resourceIds: string[] = [] @@ -288,11 +307,11 @@ export function useFolderRowDragDrop({ if (source.id === targetFolderId) return null if (targetFolderId !== null && descendantsByFolderId.get(source.id)?.has(targetFolderId)) return null - if ((getFolderParentId(source.id) ?? null) === targetFolderId) continue + if (getFolderParentId(source.id) === targetFolderId) continue folderIds.push(source.id) continue } - if ((getResourceFolderId(source.id) ?? null) === targetFolderId) continue + if (getResourceFolderId(source.id) === targetFolderId) continue resourceIds.push(source.id) } @@ -335,22 +354,47 @@ export function useFolderRowDragDrop({ * Read the selection in display order rather than insertion order, so a shift-range * drag carries its rows the way the user sees them. */ - const sourceRowIds = selection?.selectedRowIds.has(rowId) + const selectedSourceRowIds = selection?.selectedRowIds.has(rowId) ? selection.visibleRowIds.filter((visibleRowId) => selection.selectedRowIds.has(visibleRowId) ) : [rowId] if (selection && !selection.selectedRowIds.has(rowId)) selection.replaceSelection([rowId]) + const carried = dropRowsCarriedByDraggedFolders( + splitFolderedRowIds(selectedSourceRowIds), + optionsRef.current + ) + const keptIds = new Set([...carried.folderIds.map(folderRowId), ...carried.resourceIds]) + const sourceRowIds = owner + ? selectedSourceRowIds.filter((id) => keptIds.has(id)) + : selectedSourceRowIds + if (sourceRowIds.length === 0) { + e.preventDefault() + endDrag() + return + } + sourceParentsRef.current = new Map( + sourceRowIds.map((rowId) => { + const parsed = parseFolderedRowId(rowId) + return [ + rowId, + parsed.kind === 'folder' + ? optionsRef.current.getFolderParentId(parsed.id) + : optionsRef.current.getResourceFolderId(parsed.id), + ] + }) + ) draggedRowIdsRef.current = sourceRowIds setDraggedRowIds(new Set(sourceRowIds)) e.dataTransfer.effectAllowed = 'move' - writeRowDragPayload(e.dataTransfer, dragMime, sourceRowIds) + writeRowDragPayload(e.dataTransfer, dragMime, sourceRowIds, owner) dragGhost.attach(e, optionsRef.current.getRowLabel(sourceRowIds[0]), sourceRowIds.length) }, onDragOver: (e: DragEvent, rowId) => { + if (!canEdit || parseFolderedRowId(rowId).kind !== 'folder') return const sourceRowIds = draggedRowIdsRef.current const isExternal = optionsRef.current.externalDrop?.matches(e.dataTransfer) ?? false if (isExternal) { @@ -407,6 +451,7 @@ export function useFolderRowDragDrop({ ) }, onDrop: (e: DragEvent, rowId) => { + if (!canEdit) return e.preventDefault() e.stopPropagation() @@ -426,7 +471,8 @@ export function useFolderRowDragDrop({ // Prefer the dataTransfer payload over the ref so a drag that started in another // mount of this page still resolves to real row ids. const sourceRowIds = - readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + readRowDragPayload(e.dataTransfer, dragMime, owner) ?? + (owner ? [] : draggedRowIdsRef.current) const move = target.kind === 'folder' && sourceRowIds.length > 0 ? resolveMove(rowId, sourceRowIds) @@ -453,6 +499,7 @@ export function useFolderRowDragDrop({ breadcrumb: { activeIndex: activeDropTarget?.kind === 'crumb' ? activeDropTarget.index : null, onDragOver: (e: DragEvent, folderId: string | null, index: number) => { + if (!canEdit) return if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return const sourceRowIds = draggedRowIdsRef.current const canDrop = @@ -479,11 +526,13 @@ export function useFolderRowDragDrop({ ) }, onDrop: (e: DragEvent, folderId: string | null) => { + if (!canEdit) return if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return e.preventDefault() e.stopPropagation() const sourceRowIds = - readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + readRowDragPayload(e.dataTransfer, dragMime, owner) ?? + (owner ? [] : draggedRowIdsRef.current) const move = sourceRowIds.length > 0 ? resolveMoveToFolder(folderId, sourceRowIds) : null if (move) springNav.markDropHandled() endDrag() @@ -493,6 +542,7 @@ export function useFolderRowDragDrop({ body: { isActive: activeDropTarget?.kind === 'body', onDragOver: (e: DragEvent) => { + if (!canEdit) return /** Declined: a page-level upload overlay owns the whole region for an OS file drag. */ if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return const sourceRowIds = draggedRowIdsRef.current @@ -520,6 +570,7 @@ export function useFolderRowDragDrop({ setActiveDropTarget((current) => (current?.kind === 'body' ? null : current)) }, onDrop: (e: DragEvent) => { + if (!canEdit) return if (optionsRef.current.externalDrop?.matches(e.dataTransfer)) return /** * Read from the ref, not the closure. This config is memoized, and during a drag the @@ -533,7 +584,8 @@ export function useFolderRowDragDrop({ e.preventDefault() e.stopPropagation() const sourceRowIds = - readRowDragPayload(e.dataTransfer, dragMime) ?? draggedRowIdsRef.current + readRowDragPayload(e.dataTransfer, dragMime, owner) ?? + (owner ? [] : draggedRowIdsRef.current) const move = sourceRowIds.length > 0 ? resolveMoveToFolder(targetFolderId, sourceRowIds) : null if (move) springNav.markDropHandled() @@ -547,6 +599,7 @@ export function useFolderRowDragDrop({ draggedRowIds, canEdit, dragMime, + owner, editingRowId, resolveMove, resolveMoveToFolder, diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx index 7ded34f10d0..76fdb7ac164 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/action-bar/action-bar.tsx @@ -11,7 +11,7 @@ import { Tooltip, Trash, } from '@sim/emcn' -import { Download } from '@sim/emcn/icons' +import { Download, Duplicate } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' @@ -40,6 +40,7 @@ export interface ResourceActionBarProps { selectedCount: number /** Omit on lists with nothing to download (tables, knowledge bases). */ onDownload?: () => void + onCopy?: () => void /** Both `onMove` and `moveOptions` are required for the move menu to appear. */ onMove?: (optionValue: string) => void moveOptions?: MoveOptionNode[] @@ -71,6 +72,7 @@ export interface ResourceActionBarProps { export function ResourceActionBar({ selectedCount, onDownload, + onCopy, onMove, moveOptions, onDelete, @@ -111,6 +113,14 @@ export function ResourceActionBar({ disabled={actionsDisabled} /> )} + {onCopy && ( + + )} {onMove && moveOptions && ( diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 95bf5999af8..f3005d17fa7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -87,6 +87,7 @@ export interface ResourceRow { } export interface SelectableConfig { + selectAllLabel?: string selectedIds: Set onSelectRow: (id: string, checked: boolean, shiftKey?: boolean) => void onSelectAll: (checked: boolean) => void @@ -403,7 +404,7 @@ const ResourceTable = memo(function ResourceTable({ checked={selectable.isAllSelected} onCheckedChange={handleSelectAll} disabled={selectable.disabled} - aria-label='Select all' + aria-label={selectable.selectAllLabel ?? 'Select all'} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx index b4808ec50dd..9290d6ae33e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/[fileId]/page.tsx @@ -2,9 +2,10 @@ import { Suspense } from 'react' import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { getSession } from '@/lib/auth' +import { isProjectFileApiEnabled } from '@/lib/projects/rollout.server' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import FilesFileLoading from '@/app/workspace/[workspaceId]/files/[fileId]/loading' -import { Files } from '@/app/workspace/[workspaceId]/files/files' +import { FilesBrowser } from '@/app/workspace/[workspaceId]/files/browser' import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' export const metadata: Metadata = { @@ -41,7 +42,7 @@ export default async function FilesFilePage({ return ( }> - + ) diff --git a/apps/sim/app/workspace/[workspaceId]/files/browser.tsx b/apps/sim/app/workspace/[workspaceId]/files/browser.tsx new file mode 100644 index 00000000000..2ebf08a93e7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/browser.tsx @@ -0,0 +1,124 @@ +'use client' + +import { TabStrip } from '@sim/emcn' +import { useParams } from 'next/navigation' +import { useQueryStates } from 'nuqs' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { + FileNavigationProvider, + useFileNavigation, +} from '@/app/workspace/[workspaceId]/files/components/file-detail' +import { Files } from '@/app/workspace/[workspaceId]/files/files' +import FilesLoading from '@/app/workspace/[workspaceId]/files/loading' +import { ProjectFiles } from '@/app/workspace/[workspaceId]/files/project-files' +import { + fileOwnerParsers, + filesFilterParsers, + filesFilterUrlKeys, + filesParsers, + projectFilesScopeParsers, +} from '@/app/workspace/[workspaceId]/files/search-params' +import { useWorkspaceProject } from '@/hooks/queries/project-files' + +interface FilesBrowserProps { + projectFilesEnabled: boolean +} + +export function FilesBrowser({ projectFilesEnabled }: FilesBrowserProps) { + const { workspaceId, fileId } = useParams<{ workspaceId: string; fileId?: string }>() + const [{ owner, projectId }] = useQueryStates(fileOwnerParsers) + const parent = useWorkspaceProject(workspaceId, projectFilesEnabled) + const project = parent.data?.project + + if (owner === 'project' && (!projectFilesEnabled || parent.error)) { + return ( +
+ Project files are unavailable. +
+ ) + } + if (owner === 'project' && !project) return + if (owner === 'project' && projectId && projectId !== project?.id) { + return ( +
+ This Project does not contain this environment. +
+ ) + } + + const fileOwner: EditableFileOwner = + owner === 'project' && project + ? { entityType: 'project', entityId: project.id } + : { entityType: 'workspace', entityId: workspaceId } + + return ( + +
+ {project && projectFilesEnabled && ( + + )} +
+ {owner === 'project' && project ? ( + + ) : ( + + )} +
+
+
+ ) +} + +interface FileOwnerTabsProps { + owner: EditableFileOwner + projectId: string + workspaceId: string +} + +function FileOwnerTabs({ owner, projectId, workspaceId }: FileOwnerTabsProps) { + const { navigate } = useFileNavigation({ owner }) + const { fileId } = useParams<{ fileId?: string }>() + const [, setLocation] = useQueryStates({ + ...fileOwnerParsers, + ...filesParsers, + ...projectFilesScopeParsers, + }) + const [, setFilters] = useQueryStates(filesFilterParsers, filesFilterUrlKeys) + return ( + { + if (next === owner.entityType || (next !== 'project' && next !== 'workspace')) return + if (fileId) { + navigate( + next === 'project' + ? `/workspace/${encodeURIComponent(workspaceId)}/files?owner=project&projectId=${encodeURIComponent(projectId)}` + : `/workspace/${encodeURIComponent(workspaceId)}/files` + ) + return + } + void setFilters({ uploadedBy: null }) + void setLocation( + { + owner: next, + projectId: next === 'project' ? projectId : null, + folderId: null, + shareFileId: null, + historyFileId: null, + new: null, + scope: null, + }, + { history: 'push' } + ) + }} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-copy-modal.test.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-copy-modal.test.tsx new file mode 100644 index 00000000000..6bd1b60a6e0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-copy-modal.test.tsx @@ -0,0 +1,97 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { FileCopyModal } from '@/app/workspace/[workspaceId]/files/components/file-copy-modal' +import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { workspaceKeys } from '@/hooks/queries/workspace' + +vi.mock( + 'next/navigation', + async () => (await import('@sim/testing/mocks/next-navigation.mock')).nextNavigationMock +) + +let client: QueryClient +let root: Root +let container: HTMLDivElement +const requests: string[] = [] +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + vi.useFakeTimers() + requests.length = 0 + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + client.setQueryData(workspaceKeys.list(), { workspaces: [] }) + vi.stubGlobal('fetch', async (input: string) => { + requests.push(input) + return Response.json( + input.startsWith('/api/projects') + ? { + projects: [ + { + id: 'project', + name: 'Cached Project', + organizationId: null, + ownerId: 'user', + archivedAt: null, + createdAt: '2026-10-08T00:00:00Z', + updatedAt: '2026-10-08T00:00:00Z', + environments: [], + capabilities: { administer: true, issues: true }, + }, + ], + nextCursor: 'next-page', + } + : { workspaces: [] } + ) + }) + container = document.createElement('div') + document.body.append(container) + root = createRoot(container) +}) +afterEach(async () => { + await act(async () => root.unmount()) + client.clear() + container.remove() + vi.useRealTimers() +}) +async function render(projects: boolean, projectFiles: boolean) { + await act(async () => + root.render( + + + {}} + /> + + + ) + ) +} + +it.each([ + [false, true], + [true, false], + [false, false], +])('stops Project destination reads after flags become %s/%s', async (projects, projectFiles) => { + await render(true, true) + await act(async () => vi.advanceTimersByTimeAsync(10)) + expect(requests.some((url) => url.startsWith('/api/projects'))).toBe(true) + requests.length = 0 + await render(projects, projectFiles) + await act(async () => client.invalidateQueries()) + expect(requests.filter((url) => url.startsWith('/api/projects'))).toEqual([]) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-copy-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-copy-modal.tsx new file mode 100644 index 00000000000..c4ab22301b6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-copy-modal.tsx @@ -0,0 +1,244 @@ +'use client' + +import { type ReactNode, useMemo, useState } from 'react' +import { + Chip, + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + ChipSelect, + toast, +} from '@sim/emcn' +import { Duplicate } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import type { FileCopySource } from '@/lib/api/contracts/file-copy-input' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { useCopyFileItems, useFileCopyDestination } from '@/hooks/queries/file-copy' +import { useProjects } from '@/hooks/queries/projects' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +interface FileCopyModalProps { + source: FileCopySource + onClose: () => void +} + +interface CopyDestination { + owner: EditableFileOwner + name: string +} + +export function FileCopyModal({ source, onClose }: FileCopyModalProps) { + if ( + source.fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + source.folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) { + return ( + { + if (!open) onClose() + }} + srTitle='Copy selection too large' + > + + Selection too large + + +

+ Select up to {MAX_WORKSPACE_FILE_BULK_REQUEST_IDS.toLocaleString()} files and{' '} + {MAX_WORKSPACE_FILE_BULK_REQUEST_IDS.toLocaleString()} folders per copy. Reduce your + selection and try again. +

+
+ +
+ ) + } + return +} + +function FileCopyDestinationPicker({ source, onClose }: FileCopyModalProps) { + const workspaces = useWorkspacesQuery() + const projectsEnabled = useFeatureFlag('projects') + const projectFilesEnabled = useFeatureFlag('project-files') + const projectDestinationsEnabled = projectsEnabled && projectFilesEnabled + const projects = useProjects(undefined, { enabled: projectDestinationsEnabled }) + const copy = useCopyFileItems() + const [selection, setSelection] = useState('') + const destinations = useMemo(() => { + const result = new Map() + for (const workspace of workspaces.data ?? []) + result.set(`workspace:${workspace.id}`, { + owner: { entityType: 'workspace', entityId: workspace.id }, + name: workspace.name, + }) + for (const page of projectDestinationsEnabled ? (projects.data?.pages ?? []) : []) + for (const project of page.projects) + result.set(`project:${project.id}`, { + owner: { entityType: 'project', entityId: project.id }, + name: project.name, + }) + return result + }, [workspaces.data, projects.data, projectDestinationsEnabled]) + const destination = destinations.get(selection) + const groups = ['project', 'workspace'].map((kind) => ({ + section: kind === 'project' ? 'Projects' : 'Environments', + items: [...destinations] + .filter(([, item]) => item.owner.entityType === kind) + .map(([value, item]) => ({ value, label: item.name })), + })) + const destinationField = ( + + + {projectDestinationsEnabled && projects.hasNextPage && ( + void projects.fetchNextPage()}> + {projects.isFetchingNextPage ? 'Loading...' : 'Load more Projects'} + + )} + + ) + + if (destination) + return ( + + ) + return ( + { + if (!open) onClose() + }} + srTitle='Copy files' + > + + Copy to + + {destinationField} + + + ) +} + +interface CopyDestinationFormProps extends FileCopyModalProps { + destination: CopyDestination + destinationField: ReactNode + copy: ReturnType +} + +function CopyDestinationForm({ + source, + destination, + onClose, + destinationField, + copy, +}: CopyDestinationFormProps) { + const folders = useFileCopyDestination(destination.owner) + const [folderId, setFolderId] = useState('root') + const ready = folders.isFetchedAfterMount && !folders.isError && folders.data?.canWrite + const folder = folders.data?.folders.find((item) => item.id === folderId) + const validFolder = folderId === 'root' || Boolean(folder) + const count = source.fileIds.length + source.folderIds.length + + async function submit() { + if (!ready || !validFolder || copy.isPending) return + try { + await copy.mutateAsync({ + source, + destination: { owner: destination.owner, folderId: folder?.id ?? null }, + }) + toast.success(`Copied ${count === 1 ? 'item' : `${count} items`} to ${destination.name}`) + onClose() + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to copy files')) + } + } + + return ( + { + if (!open) onClose() + }} + dismissDisabled={copy.isPending} + srTitle='Copy files' + > + + Copy to + + + {destinationField} + + ({ + value: folder.id, + label: folder.path || folder.name, + })), + ]} + /> + + + void submit(), + }} + /> + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/detail.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/detail.tsx new file mode 100644 index 00000000000..fdf9c59ac54 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/detail.tsx @@ -0,0 +1,140 @@ +'use client' + +import { type ComponentProps, useCallback, useEffect, useState } from 'react' +import { toast } from '@sim/emcn' +import { Columns2, Eye, Pencil } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import type { ResourceAction } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' +import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource' +import { useFileNavigation } from '@/app/workspace/[workspaceId]/files/components/file-detail/navigation' +import { + FileViewer, + isCsvStreamOnly, + isMarkdownFile, + isPreviewable, + isTextEditable, + type PreviewMode, +} from '@/app/workspace/[workspaceId]/files/components/file-viewer' +import { FileDocAvatars } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-avatars' +import { FileDocRoomProvider } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-room-context' + +interface FileDetailProps { + viewer: ComponentProps + header: Omit, 'aside'> + showSaveAction?: boolean +} + +/** Shared editor chrome; owner-specific queries and commands remain in their browser adapters. */ +export function FileDetail({ viewer, header, showSaveAction = false }: FileDetailProps) { + const owner = viewer.owner ?? { entityType: 'workspace' as const, entityId: viewer.workspaceId } + const navigation = useFileNavigation({ owner }) + const [previewMode, setPreviewMode] = useState(() => + !viewer.autoFocus && isPreviewable(viewer.file) ? 'preview' : 'editor' + ) + const { save, setSaveStatus } = navigation + const handleSave = useCallback(() => { + void save().catch((error) => toast.error(getErrorMessage(error, 'Unable to save this file'))) + }, [save]) + const handleSaveStatusChange = useCallback( + (status: 'idle' | 'saving' | 'saved' | 'error', retry?: () => Promise) => { + setSaveStatus(status) + if (status === 'error') { + toast.error(`Failed to save "${viewer.file.name}"`, { + action: { + label: 'Retry', + onClick: () => + void retry?.().catch((error) => + toast.error(getErrorMessage(error, 'Unable to save this file')) + ), + }, + }) + } + }, + [setSaveStatus, viewer.file.name] + ) + + useEffect(() => { + function handleKeyDown(event: KeyboardEvent) { + if ((event.metaKey || event.ctrlKey) && event.key === 's') { + event.preventDefault() + handleSave() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [handleSave]) + + if (navigation.fileId !== viewer.file.id) + throw new Error('File detail does not match its navigation scope') + + const streamOnly = isCsvStreamOnly(viewer.file) + const canPreview = + isPreviewable(viewer.file) && + !streamOnly && + !isMarkdownFile(viewer.file) && + viewer.file.type !== SIM_PAGE_CONTENT_TYPE + const hasSplitView = canPreview && isTextEditable(viewer.file) + const actions: ResourceAction[] = [ + ...(canPreview + ? [ + { + text: hasSplitView + ? previewMode === 'editor' + ? 'Split' + : previewMode === 'split' + ? 'Preview' + : 'Edit' + : previewMode === 'preview' + ? 'Edit' + : 'Preview', + icon: + hasSplitView && previewMode === 'editor' + ? Columns2 + : previewMode === 'preview' + ? Pencil + : Eye, + onSelect: () => + setPreviewMode((current) => + hasSplitView + ? current === 'editor' + ? 'split' + : current === 'split' + ? 'preview' + : 'editor' + : current === 'preview' + ? 'editor' + : 'preview' + ), + }, + ] + : []), + ...(showSaveAction && viewer.canEdit && isTextEditable(viewer.file) && !streamOnly + ? [ + { + id: 'save', + text: 'Save', + disabled: !navigation.isDirty || navigation.saveStatus === 'saving', + onSelect: handleSave, + }, + ] + : []), + ...(header.actions ?? []), + ] + return ( + + + } /> + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/index.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/index.ts new file mode 100644 index 00000000000..20be81feffa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/index.ts @@ -0,0 +1,2 @@ +export { FileDetail } from './detail' +export { FileNavigationProvider, useFileNavigation } from './navigation' diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/navigation.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/navigation.tsx new file mode 100644 index 00000000000..b3ce615a395 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-detail/navigation.tsx @@ -0,0 +1,105 @@ +'use client' + +import { createContext, type ReactNode, useCallback, useContext, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { useSettingsUnsavedGuard } from '@/components/settings/use-settings-unsaved-guard' +import type { FileDownloadSource } from '@/lib/uploads/client/download' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +type SaveStatus = 'idle' | 'saving' | 'saved' | 'error' + +interface FileNavigationController { + owner: EditableFileOwner + fileId: string | null + saveRef: React.MutableRefObject<(() => Promise) | null> + discardRef: React.MutableRefObject<(() => void) | null> + downloadSourceRef: React.MutableRefObject + isDirty: boolean + saveStatus: SaveStatus + setIsDirty: (dirty: boolean) => void + setSaveStatus: (status: SaveStatus) => void + navigate: (url: string) => void + save: () => Promise +} + +const FileNavigationContext = createContext(null) + +interface FileNavigationProviderProps { + owner: EditableFileOwner + fileId: string | null + children: ReactNode +} + +/** One draft scope surrounds both owner tabs and the current file's detail view. */ +export function FileNavigationProvider({ owner, fileId, children }: FileNavigationProviderProps) { + const saveRef = useRef<(() => Promise) | null>(null) + const discardRef = useRef<(() => void) | null>(null) + const downloadSourceRef = useRef(null) + const dirtyRef = useRef(false) + const savingRef = useRef('idle') + const router = useRouter() + const [isDirty, updateIsDirty] = useState(false) + const [saveStatus, updateSaveStatus] = useState('idle') + const setIsDirty = useCallback((dirty: boolean) => { + dirtyRef.current = dirty + updateIsDirty(dirty) + }, []) + const setSaveStatus = useCallback((status: SaveStatus) => { + savingRef.current = status + updateSaveStatus(status) + }, []) + const { guardBack } = useSettingsUnsavedGuard({ + isDirty, + navigationBlocked: saveStatus === 'saving', + onDiscard: () => { + discardRef.current?.() + setIsDirty(false) + setSaveStatus('idle') + }, + }) + const navigate = useCallback( + (url: string) => guardBack(() => router.push(url)), + [guardBack, router] + ) + const save = useCallback(async () => { + if (saveRef.current && dirtyRef.current && savingRef.current !== 'saving') { + await saveRef.current() + } + }, []) + + return ( + + {children} + + ) +} + +interface UseFileNavigationProps { + owner: EditableFileOwner +} + +export function useFileNavigation({ owner }: UseFileNavigationProps) { + const controller = useContext(FileNavigationContext) + if ( + !controller || + controller.owner.entityType !== owner.entityType || + controller.owner.entityId !== owner.entityId + ) { + throw new Error('File navigation requires its explicit owner scope') + } + return controller +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-extraction-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-extraction-modal.tsx new file mode 100644 index 00000000000..635ede524d5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-extraction-modal.tsx @@ -0,0 +1,43 @@ +import { ChipConfirmModal } from '@sim/emcn' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { useExtractFile } from '@/hooks/queries/file-extraction' + +interface FileExtractionModalProps { + owner: EditableFileOwner + fileId: string + fileName: string + canWrite: boolean + onClose: () => void +} + +export function FileExtractionModal({ + owner, + fileId, + fileName, + canWrite, + onClose, +}: FileExtractionModalProps) { + const extract = useExtractFile(owner) + return ( + { + if (!open && !extract.isPending) onClose() + }} + title='Unzip archive?' + defaultAction='confirm' + text={['This will unzip ', { text: fileName, bold: true }, ' into a new folder beside it.']} + confirm={{ + label: 'Unzip', + onClick: () => { + if (!canWrite || extract.isPending) return + extract.mutate({ fileId, fileName }, { onSuccess: onClose }) + }, + variant: 'primary', + pending: extract.isPending, + pendingLabel: 'Unzipping...', + disabled: !canWrite, + }} + /> + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-filter-controls.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-filter-controls.tsx new file mode 100644 index 00000000000..3a22020e1a7 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-filter-controls.tsx @@ -0,0 +1,106 @@ +'use client' + +import { Chip, ChipCombobox, type ComboboxOption } from '@sim/emcn' +import { FILE_BROWSER_SIZE_OPTIONS, FILE_BROWSER_TYPE_OPTIONS } from '@/lib/workspace-files/browser' +import { + ResourceFilterPanel, + ResourceFilterSection, +} from '@/app/workspace/[workspaceId]/components/resource/components/resource-options' + +interface FileFilterControlsProps { + types: string[] + sizes: string[] + creatorIds: string[] + creators: ComboboxOption[] + onTypes: (values: string[]) => void + onSizes: (values: string[]) => void + onCreators: (values: string[]) => void + onClear: () => void +} + +export function FileFilterControls({ + types, + sizes, + creatorIds, + creators, + onTypes, + onSizes, + onCreators, + onClear, +}: FileFilterControlsProps) { + const typeLabel = + types.length === 0 + ? 'All' + : types.length === 1 + ? (FILE_BROWSER_TYPE_OPTIONS.find((option) => option.value === types[0])?.label ?? types[0]) + : `${types.length} selected` + const sizeLabel = + sizes.length === 0 + ? 'All' + : sizes.length === 1 + ? (FILE_BROWSER_SIZE_OPTIONS.find((option) => option.value === sizes[0])?.label.split( + ' (' + )[0] ?? sizes[0]) + : `${sizes.length} selected` + const creatorLabel = + creatorIds.length === 0 + ? 'All' + : creatorIds.length === 1 + ? (creators.find((option) => option.value === creatorIds[0])?.label ?? '1 creator') + : `${creatorIds.length} creators` + return ( + + + + + + + + {creators.length > 0 && ( + + + + )} + {(types.length > 0 || sizes.length > 0 || creatorIds.length > 0) && ( + + Clear all filters + + )} + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-history-modal.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-history-modal.tsx new file mode 100644 index 00000000000..2600b848c6a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-history-modal.tsx @@ -0,0 +1,222 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipConfirmModal, + ChipModal, + ChipModalBody, + ChipModalField, + ChipModalFooter, + ChipModalHeader, + cn, + OverflowText, + scrollFadeAttributes, + scrollFadeClass, + toast, + useScrollEdges, +} from '@sim/emcn' +import { Clock, Download, RefreshCw } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import type { V2FileVersion } from '@/lib/api/contracts/v2/file-versions' +import { saveBlob } from '@/lib/uploads/client/download' +import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { useFileNavigation } from '@/app/workspace/[workspaceId]/files/components/file-detail' +import { + useDownloadFileVersion, + useFileHistory, + useRevertFileVersion, +} from '@/hooks/queries/file-history' + +interface FileHistoryModalProps { + owner: EditableFileOwner + fileId: string + fileName: string + canWrite: boolean + onClose: () => void +} + +export function FileHistoryModal({ + owner, + fileId, + fileName, + canWrite, + onClose, +}: FileHistoryModalProps) { + const history = useFileHistory(owner, fileId, true) + const revert = useRevertFileVersion(owner, fileId) + const download = useDownloadFileVersion(owner, fileId) + const navigation = useFileNavigation({ owner }) + const [scrollElement, setScrollElement] = useState(null) + const [target, setTarget] = useState<{ version: number; expectedRevision: string } | null>(null) + const edges = useScrollEdges(scrollElement) + const versions = history.data?.pages.flatMap((page) => page.versions) ?? [] + const revision = history.data?.pages[0]?.revision + const hasDraft = + navigation.fileId === fileId && (navigation.isDirty || navigation.saveStatus === 'saving') + const canRevert = + canWrite && history.isFetchedAfterMount && !history.isError && Boolean(revision) && !hasDraft + + async function downloadVersion(version: number) { + try { + const response = await download.mutateAsync(version) + saveBlob(await response.blob(), `v${version}-${fileName}`) + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to download version')) + } + } + + async function confirmRevert() { + if (!target || !canRevert) return + try { + const result = await revert.mutateAsync(target) + setTarget(null) + toast.success(result.reverted ? 'File reverted' : 'This version is already current') + } catch (error) { + toast.error(getErrorMessage(error, 'Failed to revert file')) + setTarget(null) + } + } + + return ( + <> + { + if (!open) onClose() + }} + size='lg' + dismissDisabled={revert.isPending} + srTitle={`Version history for ${fileName}`} + > + + Version History + + + +
+
+ {history.isPending && ( +

Loading history...

+ )} + {!history.isPending && !history.isError && versions.length === 0 && ( +

No recorded versions.

+ )} + {versions.map((version) => ( + void downloadVersion(version.version)} + onRevert={() => { + if (revision) + setTarget({ version: version.version, expectedRevision: revision }) + }} + /> + ))} + {history.hasNextPage && ( + void history.fetchNextPage()} + > + {history.isFetchingNextPage ? 'Loading...' : 'Load more'} + + )} +
+
+
+
+ , + disabled: history.isFetching || revert.isPending, + onClick: () => void history.refetch(), + }, + ]} + /> +
+ { + if (!open) setTarget(null) + }} + title={`Revert to version ${target?.version ?? ''}?`} + text='This writes the selected content as a new version. Your current version remains in history.' + confirm={{ + label: 'Revert', + variant: 'primary', + pending: revert.isPending, + pendingLabel: 'Reverting...', + disabled: !canRevert, + onClick: () => void confirmRevert(), + }} + /> + + ) +} + +interface FileHistoryRowProps { + version: V2FileVersion + busy: boolean + canRevert: boolean + onDownload: () => void + onRevert: () => void +} + +function FileHistoryRow({ version, busy, canRevert, onDownload, onRevert }: FileHistoryRowProps) { + const authors = version.authors + .map((author) => author.email ?? `Deleted user (${author.id})`) + .join(', ') + return ( +
+
+

+ Version {version.version} + {version.isCurrent ? ' · Current' : ''} +

+ +

+ {new Date(version.updatedAt).toLocaleString()} +

+
+ + Download + + {!version.isCurrent && canRevert && ( + + Revert + + )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx index 64de7862464..f353deb7aba 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-row-context-menu/file-row-context-menu.tsx @@ -15,7 +15,7 @@ import { FolderInput, Pencil, } from '@sim/emcn' -import { Download, Link, Pin, Send, Trash } from '@sim/emcn/icons' +import { Clock, Download, Duplicate, Link, Pin, Send, Trash } from '@sim/emcn/icons' import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' import { renderMoveOption } from '@/app/workspace/[workspaceId]/components/folders' import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label' @@ -27,11 +27,13 @@ interface FileRowContextMenuProps { onOpen: () => void onCopyLink?: () => void onDownload?: () => void + onHistory?: () => void + onCopy?: () => void onRename: () => void onDelete: () => void onMove?: (optionValue: string) => void onShare?: () => void - onTogglePin: () => void + onTogglePin?: () => void /** Pin state of the right-clicked row, driving the Pin/Unpin label. */ pinned: boolean moveOptions?: MoveOptionNode[] @@ -46,6 +48,8 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ onOpen, onCopyLink, onDownload, + onHistory, + onCopy, onRename, onDelete, onMove, @@ -66,7 +70,10 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ * @see `.claude/rules/sim-list-ordering.md` — one rule, before the destructive group. */ const hasActionsAboveDestructive = - !isMultiSelect || !!onDownload || (!!onMove && !!moveOptions && moveOptions.length > 0) + !isMultiSelect || + !!onDownload || + !!onCopy || + (!!onMove && !!moveOptions && moveOptions.length > 0) return ( !open && onClose()} modal={false}> @@ -102,7 +109,19 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({ {selectionActionLabel('Download', selectedCount)} )} - {!isMultiSelect && ( + {!isMultiSelect && onHistory && ( + + + Version History + + )} + {onCopy && ( + + + {selectionActionLabel('Copy', selectedCount, 'Copy to...')} + + )} + {!isMultiSelect && onTogglePin && ( {pinned ? 'Unpin' : 'Pin'} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-upload-overlay.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-upload-overlay.tsx new file mode 100644 index 00000000000..c8c746e51d1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-upload-overlay.tsx @@ -0,0 +1,19 @@ +import { Upload } from '@sim/emcn/icons' + +interface FileUploadOverlayProps { + destination: string +} + +export function FileUploadOverlay({ destination }: FileUploadOverlayProps) { + return ( +
+ +
+

Drop to upload

+

+ Release files here to add them to {destination} +

+
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx index fcc727388a5..90c1c57e364 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/chart-preview.tsx @@ -20,7 +20,7 @@ const CHART_HEADER_HEIGHT = 24 interface ChartPreviewProps { content: string - workspaceId: string + workspaceId: string | undefined isStreaming?: boolean } @@ -50,6 +50,7 @@ export function ChartPreview({ content, workspaceId, isStreaming = false }: Char if (parseError && isStreaming) return const error = parseError ?? + (tableSource && !workspaceId ? 'Table-backed charts require a workspace.' : null) ?? (tableSource && (rowsQuery.isError || tableQuery.isError) ? getErrorMessage(rowsQuery.error ?? tableQuery.error, 'Failed to read table') : null) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts index efd99ab7b40..be23c974d59 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-import.ts @@ -11,12 +11,10 @@ import { useImportTrayStore } from '@/stores/table/import-tray/store' export type CsvImportFileDescriptor = Pick /** - * Wires the "Import as a table" affordance for a capped CSV preview. When the preview is - * `truncated`, raises a one-time warning toast whose action kicks off a background import of the - * existing workspace file — no re-upload, source preserved — and navigates to the new table. + * Warns when a CSV preview is capped. Editable workspace files also offer a background table import. */ export function useCsvTruncationImport( - workspaceId: string, + workspaceId: string | undefined, file: CsvImportFileDescriptor, truncated: boolean, readOnly = false @@ -29,7 +27,7 @@ export function useCsvTruncationImport( const importingRef = useRef(false) const importAsTable = useCallback(() => { - if (importingRef.current) return + if (!workspaceId || importingRef.current) return importingRef.current = true let importId: string | null = null toast.success(`Importing "${file.name}" as a table`, { @@ -64,14 +62,18 @@ export function useCsvTruncationImport( // eslint-disable-next-line react-hooks/exhaustive-deps }, [workspaceId, file.id, file.key, file.name]) - // Surface the cap as a warning toast with an import action, once per file. const notifiedKeyRef = useRef(null) useEffect(() => { - if (readOnly || !truncated || notifiedKeyRef.current === file.key) return + if (!truncated || notifiedKeyRef.current === file.key) return notifiedKeyRef.current = file.key - toast.warning(`Showing the first ${CSV_PREVIEW_MAX_ROWS.toLocaleString()} rows`, { - description: 'Import this file as a table to view all of its rows.', - action: { label: 'Import as a table', onClick: importAsTable }, - }) - }, [readOnly, truncated, file.key, importAsTable]) + toast.warning( + `Showing the first ${CSV_PREVIEW_MAX_ROWS.toLocaleString()} rows`, + workspaceId && !readOnly + ? { + description: 'Import this file as a table to view all of its rows.', + action: { label: 'Import as a table', onClick: importAsTable }, + } + : { description: 'This preview does not include all rows.' } + ) + }, [workspaceId, readOnly, truncated, file.key, importAsTable]) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx index ecdd766ee21..f7b3335b087 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/csv-table-preview.tsx @@ -1,7 +1,7 @@ 'use client' import { memo } from 'react' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { ViewerFileRecord } from '@/app/workspace/[workspaceId]/files/components/file-viewer/types' import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll' import { useWorkspaceCsvPreview } from '@/hooks/queries/workspace-file-table' import { useCsvTruncationImport } from './csv-import' @@ -17,8 +17,8 @@ export const CsvTablePreview = memo(function CsvTablePreview({ file, workspaceId, }: { - file: WorkspaceFileRecord - workspaceId: string + file: ViewerFileRecord + workspaceId: string | undefined }) { const scrollRef = useHorizontalWheelScroll() const version = Number(new Date(file.updatedAt)) || file.size diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx index ddf44547039..568c98b3239 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/docx-preview.tsx @@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { sanitizeRenderedHyperlinks, stripEmbeddedFrames } from '@/lib/core/security/url-safety' import { assertOoxmlPreviewWithinLimits } from '@/lib/file-parsers/ooxml-preview-guard' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { ViewerFileRecord } from '@/app/workspace/[workspaceId]/files/components/file-viewer/types' import { PREVIEW_LOADING_OVERLAY, PreviewError, resolvePreviewError } from './preview-shared' import { PreviewToolbar } from './preview-toolbar' import { useDocPreviewBinary } from './use-doc-preview-binary' @@ -67,13 +67,13 @@ export const DocxPreview = memo(function DocxPreview({ file, workspaceId, }: { - file: WorkspaceFileRecord - workspaceId: string + file: ViewerFileRecord + workspaceId: string | undefined }) { const containerRef = useRef(null) const scrollContainerRef = useRef(null) const zoomPercentRef = useRef(100) - const preview = useDocPreviewBinary(workspaceId, file) + const preview = useDocPreviewBinary({ workspaceId, file }) const fileData = preview.data const [renderError, setRenderError] = useState(null) const [rendering, setRendering] = useState(false) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index b1106cbf4cd..f69cb621006 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -1,8 +1,9 @@ 'use client' -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { type ComponentType, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Music } from '@sim/emcn/icons' import dynamic from 'next/dynamic' +import type { ProjectFileRecord } from '@/lib/api/contracts/project-files' import type { FileDownloadSource } from '@/lib/uploads/client/download' import { MAX_TEXT_PREVIEW_BYTES, @@ -10,13 +11,19 @@ import { } from '@/lib/uploads/client/text-content' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { resolveMediaMimeType } from '@/lib/uploads/utils/file-utils' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { FileOwner } from '@/lib/workspace-files/ownership' +import type { ViewerFileRecord } from '@/app/workspace/[workspaceId]/files/components/file-viewer/types' import { useWorkspaceFileBinary, useWorkspaceFileContent, useWorkspaceImageDimensionsAdapter, } from '@/hooks/queries/workspace-files' import { - createWorkspaceFileContentSource, + createOwnedFileContentSource, type FileContentSource, FileContentSourceProvider, } from '@/hooks/use-file-content-source' @@ -97,9 +104,7 @@ export function isCsvStreamOnly(file: { export type PreviewMode = 'editor' | 'split' | 'preview' -interface FileViewerProps { - file: WorkspaceFileRecord - workspaceId: string +interface FileViewerOptions { /** * Content source for this view. Defaults to a workspace-scoped source derived from `workspaceId`; * the public share page passes a token-scoped source. Provided to descendants (renderers, embedded @@ -149,19 +154,65 @@ interface FileViewerProps { enableFind?: boolean } +type FileViewerProps = FileViewerOptions & + ( + | { file: WorkspaceFileRecord; workspaceId: string; owner?: never } + | { + file: ProjectFileRecord + owner: { entityType: 'project'; entityId: string } + workspaceId?: never + } + ) + +interface FileViewerContentProps extends FileViewerOptions { + file: ViewerFileRecord + workspaceId?: string +} + +interface OwnedFileViewerProps extends FileViewerContentProps { + owner: FileOwner +} + +const FILE_VIEWER_ADAPTERS: FileOwnerAdapters> = { + workspace: WorkspaceFileViewer, + project: OwnedFileViewer, +} + export function FileViewer(props: FileViewerProps) { - const { contentSource, workspaceId } = props - // A caller-supplied contentSource means the adapter is unused (and its `workspaceId` may be a share token). - const imageDimensions = useWorkspaceImageDimensionsAdapter(workspaceId, { + const owner = props.owner ?? { entityType: 'workspace' as const, entityId: props.workspaceId } + const Viewer = requireFileOwnerAdapter(FILE_VIEWER_ADAPTERS, owner) + return +} + +function OwnedFileViewer(props: OwnedFileViewerProps) { + const { contentSource, owner, file } = props + const { entityType, entityId } = owner + const source = useMemo( + () => contentSource ?? createOwnedFileContentSource({ entityType, entityId }, file.id), + [contentSource, entityType, entityId, file.id] + ) + return ( + + + + ) +} + +function WorkspaceFileViewer(props: OwnedFileViewerProps) { + const { contentSource, owner, file } = props + const { entityType, entityId } = owner + const imageDimensions = useWorkspaceImageDimensionsAdapter(entityId, { enabled: !contentSource, }) const source = useMemo( - () => contentSource ?? createWorkspaceFileContentSource(workspaceId, imageDimensions), - [contentSource, workspaceId, imageDimensions] + () => + contentSource ?? + createOwnedFileContentSource({ entityType, entityId }, file.id, { imageDimensions }), + [contentSource, entityType, entityId, file.id, imageDimensions] ) return ( - + ) } @@ -187,7 +238,7 @@ function FileViewerContent({ collaborative, onDeriveTitleFromHeading, enableFind = false, -}: FileViewerProps) { +}: FileViewerContentProps) { const category = resolveFileCategory(file.type, file.name) if (category === 'text-editable') { @@ -304,8 +355,8 @@ const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({ file, workspaceId, }: { - file: WorkspaceFileRecord - workspaceId: string + file: ViewerFileRecord + workspaceId: string | undefined }) { const { data: content, @@ -347,10 +398,10 @@ const IframePreview = memo(function IframePreview({ file, workspaceId, }: { - file: WorkspaceFileRecord - workspaceId: string + file: ViewerFileRecord + workspaceId: string | undefined }) { - const preview = useDocPreviewBinary(workspaceId, file) + const preview = useDocPreviewBinary({ workspaceId, file }) const bufferSource = useMemo( () => (preview.data ? { kind: 'buffer', buffer: preview.data } : null), @@ -371,7 +422,13 @@ const IframePreview = memo(function IframePreview({ ) }) -function useBlobUrl(workspaceId: string, fileId: string, fileKey: string) { +interface UseBlobUrlProps { + workspaceId: string | undefined + fileId: string + fileKey: string +} + +function useBlobUrl({ workspaceId, fileId, fileKey }: UseBlobUrlProps) { const { data: fileData, isLoading, error } = useWorkspaceFileBinary(workspaceId, fileId, fileKey) const [blobUrl, setBlobUrl] = useState(null) const blobUrlRef = useRef(null) @@ -405,8 +462,8 @@ const MediaPreview = memo(function MediaPreview({ workspaceId, kind, }: { - file: WorkspaceFileRecord - workspaceId: string + file: ViewerFileRecord + workspaceId: string | undefined kind: 'audio' | 'video' }) { const { @@ -415,7 +472,7 @@ const MediaPreview = memo(function MediaPreview({ error: fetchError, blobUrl, replaceBlobUrl, - } = useBlobUrl(workspaceId, file.id, file.key) + } = useBlobUrl({ workspaceId, fileId: file.id, fileKey: file.key }) const mediaType = resolveMediaMimeType(file.type, file.name, kind) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx index f7164b0a2e9..863c25abd8d 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/image-preview.tsx @@ -1,12 +1,12 @@ 'use client' import { memo, useState } from 'react' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { ViewerFileRecord } from '@/app/workspace/[workspaceId]/files/components/file-viewer/types' import { useFileContentSource } from '@/hooks/use-file-content-source' import { PREVIEW_LOADING_OVERLAY, UnsupportedPreview } from './preview-shared' import { ZoomablePreview } from './zoomable-preview' -export const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) { +export const ImagePreview = memo(function ImagePreview({ file }: { file: ViewerFileRecord }) { const source = useFileContentSource() /** `v` busts the browser cache across content writes; `preview` lets the server * substitute a renderable JPEG for a HEIC. */ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx index 51702331a5f..0822e0b033c 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/pptx-preview.tsx @@ -2,7 +2,6 @@ import { memo, useEffect, useState } from 'react' import { createLogger } from '@sim/logger' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { PptxSandboxHost } from '@/app/workspace/[workspaceId]/files/components/file-viewer/pptx-sandbox-host' import { PREVIEW_LOADING_OVERLAY, @@ -10,6 +9,7 @@ import { PreviewLoadingFrame, resolvePreviewError, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/preview-shared' +import type { ViewerFileRecord } from '@/app/workspace/[workspaceId]/files/components/file-viewer/types' import { useDocPreviewBinary } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-doc-preview-binary' const logger = createLogger('PptxPreview') @@ -22,10 +22,10 @@ export const PptxPreview = memo(function PptxPreview({ file, workspaceId, }: { - file: WorkspaceFileRecord - workspaceId: string + file: ViewerFileRecord + workspaceId: string | undefined }) { - const preview = useDocPreviewBinary(workspaceId, file) + const preview = useDocPreviewBinary({ workspaceId, file }) const fileData = preview.data const cacheKey = pptxCacheKey(file.id, preview.dataUpdatedAt, fileData?.byteLength ?? 0) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx index f197ce717f1..6c987949ec4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx @@ -16,6 +16,7 @@ import { import { compileSimPage, isSimPageSource } from '@/lib/workspace-files/page-compile' import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll' import { useWorkspaceFileBinary } from '@/hooks/queries/workspace-files' +import { useFileContentSource } from '@/hooks/use-file-content-source' import { ChartPreview } from './chart-preview' import { type CsvImportFileDescriptor, useCsvTruncationImport } from './csv-import' import { DataTable } from './data-table' @@ -61,7 +62,7 @@ interface PreviewPanelProps { content: string mimeType: string | null filename: string - workspaceId: string + workspaceId: string | undefined fileId: string fileKey: string isStreaming?: boolean @@ -160,7 +161,7 @@ const HTML_PREVIEW_BOOTSTRAP = `