Skip to content

Commit a43c8a6

Browse files
committed
fix(files): reconcile Project browser ownership and upload lifecycle
1 parent 41cb606 commit a43c8a6

11 files changed

Lines changed: 120 additions & 73 deletions

File tree

‎apps/sim/app/projects/[projectId]/files/[fileId]/page.tsx‎

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { notFound, redirect } from 'next/navigation'
22
import { internalSessionAuth } from '@/lib/api/server/routes'
33
import { getSession } from '@/lib/auth'
4-
import { OrchestrationError } from '@/lib/core/orchestration/types'
4+
import { asOrchestrationError } from '@/lib/core/orchestration/types'
55
import { getProject } from '@/lib/projects/application/use-cases'
66
import { getProjectFileMetadata } from '@/lib/projects/files/application'
77
import { isProjectFileApiEnabled } from '@/lib/projects/rollout.server'
@@ -27,11 +27,8 @@ export default async function ProjectFilePage({ params }: ProjectFilePageProps)
2727
])
2828
workspaceId = context.project.environments[0]?.id
2929
} catch (error) {
30-
if (
31-
error instanceof OrchestrationError &&
32-
(error.code === 'not_found' || error.code === 'forbidden')
33-
)
34-
notFound()
30+
const classified = asOrchestrationError(error)
31+
if (classified?.code === 'not_found' || classified?.code === 'forbidden') notFound()
3532
throw error
3633
}
3734
if (!workspaceId) notFound()

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/preview-panel.tsx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ function useInlinedWorkspaceImages(content: string): string {
281281
if (!cache) return
282282
const urls = [
283283
...content.matchAll(
284-
/src="(\/api\/(?:files\/view\/[^"?]+|projects\/[^/"?]+\/files\/[^/"?]+\/content)(?:\?[^"]*)?)"/g
284+
/src="(\/api\/(?:files\/view\/[^"?#]+|projects\/[^/"?#]+\/files\/[^/"?#]+\/content)(?:\?[^"#]*)?)(#[^"]*)?"/g
285285
),
286286
].map((m) => m[1])
287287
const missing = [...new Set(urls)].filter((url) => !cache.has(url))
@@ -320,10 +320,10 @@ function useInlinedWorkspaceImages(content: string): string {
320320
return useMemo(
321321
() =>
322322
content.replace(
323-
/src="(\/api\/(?:files\/view\/[^"?]+|projects\/[^/"?]+\/files\/[^/"?]+\/content)(?:\?[^"]*)?)"/g,
324-
(match, url: string) => {
323+
/src="(\/api\/(?:files\/view\/[^"?#]+|projects\/[^/"?#]+\/files\/[^/"?#]+\/content)(?:\?[^"#]*)?)(#[^"]*)?"/g,
324+
(match, url: string, fragment: string | undefined) => {
325325
const blobUrl = cacheRef.current?.get(url) ?? resolved[url]
326-
return blobUrl ? `src="${blobUrl}"` : match
326+
return blobUrl ? `src="${blobUrl}${fragment ?? ''}"` : match
327327
}
328328
),
329329
[content, resolved]

‎apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,23 @@ export function LoadedRichMarkdownEditor({
605605
const containerRef = useRef<HTMLDivElement>(null)
606606
const uploadFile = useUploadWorkspaceFile()
607607
const uploadProjectFile = useUploadProjectFile()
608+
const imageUploadsRef = useRef<Set<AbortController> | null>(null)
609+
imageUploadsRef.current ??= new Set<AbortController>()
610+
611+
useEffect(() => {
612+
if (!effectiveCanEdit) {
613+
for (const controller of imageUploadsRef.current ?? []) controller.abort()
614+
}
615+
}, [effectiveCanEdit])
616+
617+
useEffect(() => {
618+
const uploads = imageUploadsRef.current
619+
if (!uploads) return
620+
return () => {
621+
for (const controller of uploads) controller.abort()
622+
uploads.clear()
623+
}
624+
}, [projectId, workspaceId, file.id])
608625
const editorInstanceRef = useRef<Editor | null>(null)
609626
const resolveImageSrcRef = useRef(source.resolveImageSrc)
610627

@@ -640,17 +657,34 @@ export function LoadedRichMarkdownEditor({
640657
const anchor = anchors[index]
641658
if (!anchor || findImageUpload(editor, anchor) === null) continue
642659
const uploadingToastId = toast.info(`Uploading "${image.name}"…`, { duration: 0 })
660+
const controller = new AbortController()
661+
imageUploadsRef.current?.add(controller)
643662
const uploadedUrl = await (projectId
644663
? uploadProjectFile
645-
.mutateAsync({ projectId, file: image, folderId: file.folderId ?? null })
664+
.mutateAsync({
665+
projectId,
666+
file: image,
667+
folderId: file.folderId ?? null,
668+
signal: controller.signal,
669+
})
646670
.then((result) => `/api/files/view/${encodeURIComponent(result.file.id)}`)
647671
: workspaceId
648672
? uploadFile
649-
.mutateAsync({ workspaceId, file: image, folderId: file.folderId ?? null })
673+
.mutateAsync({
674+
workspaceId,
675+
file: image,
676+
folderId: file.folderId ?? null,
677+
signal: controller.signal,
678+
})
650679
.then((result) => result.file.url)
651680
: Promise.reject(new Error('File owner is required'))
652681
).catch(() => null)
682+
imageUploadsRef.current?.delete(controller)
653683
toast.dismiss(uploadingToastId)
684+
if (controller.signal.aborted) {
685+
for (const pending of anchors) removeImageUpload(editor, pending)
686+
break
687+
}
654688
if (uploadedUrl) {
655689
const inserted = finishImageUpload(
656690
editor,

‎apps/sim/app/workspace/[workspaceId]/files/hooks/use-project-file-upload.ts‎

Lines changed: 48 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from 'react'
1+
import { useCallback, useEffect, useRef, useState } from 'react'
22
import { toast } from '@sim/emcn'
33
import { getErrorMessage } from '@sim/utils/errors'
44
import { useLimitUpgradeToast } from '@/lib/billing/client'
@@ -15,7 +15,7 @@ interface UploadProgress {
1515

1616
/** Captures the upload destination for a batch and retires it when its owner view unmounts. */
1717
export function useProjectFileUpload(projectId: string, canWrite: boolean) {
18-
const mutation = useUploadProjectFile()
18+
const { mutateAsync } = useUploadProjectFile()
1919
const notifyLimit = useLimitUpgradeToast()
2020
const active = useRef<AbortController | null>(null)
2121
const canWriteRef = useRef(canWrite)
@@ -34,53 +34,56 @@ export function useProjectFileUpload(projectId: string, canWrite: boolean) {
3434
if (!canWrite) active.current?.abort()
3535
}, [canWrite])
3636

37-
async function uploadFiles(files: File[], folderId: string | null) {
38-
if (!canWriteRef.current || active.current || files.length === 0) return
39-
const allowed: File[] = []
40-
for (const file of files) {
41-
if (file.size > MAX_WORKSPACE_FILE_SIZE) {
42-
toast.error(`${file.name} exceeds the 5 GiB upload limit`)
43-
} else if (!isSupportedFileUpload(file.name)) {
44-
toast.error(`Unsupported file type: ${file.name}`)
45-
} else {
46-
allowed.push(file)
47-
}
48-
}
49-
if (allowed.length === 0) return
50-
const controller = new AbortController()
51-
active.current = controller
52-
const updateProgress = (completed: number, currentPercent: number) => {
53-
if (active.current === controller && !controller.signal.aborted) {
54-
setProgress({ completed, currentPercent, total: allowed.length, folderId })
37+
const uploadFiles = useCallback(
38+
async (files: File[], folderId: string | null) => {
39+
if (!canWriteRef.current || active.current || files.length === 0) return
40+
const allowed: File[] = []
41+
for (const file of files) {
42+
if (file.size > MAX_WORKSPACE_FILE_SIZE) {
43+
toast.error(`${file.name} exceeds the 5 GiB upload limit`)
44+
} else if (!isSupportedFileUpload(file.name)) {
45+
toast.error(`Unsupported file type: ${file.name}`)
46+
} else {
47+
allowed.push(file)
48+
}
5549
}
56-
}
57-
updateProgress(0, 0)
58-
try {
59-
for (const [index, file] of allowed.entries()) {
60-
if (controller.signal.aborted || !canWriteRef.current) break
61-
try {
62-
await mutation.mutateAsync({
63-
projectId,
64-
folderId,
65-
file,
66-
signal: controller.signal,
67-
onProgress: ({ percent }) => updateProgress(index, percent),
68-
})
69-
} catch (error) {
70-
if (controller.signal.aborted) break
71-
const message = getErrorMessage(error, `Unable to upload ${file.name}`)
72-
if (/storage limit/i.test(message)) notifyLimit('storage', message)
73-
else toast.error(message)
50+
if (allowed.length === 0) return
51+
const controller = new AbortController()
52+
active.current = controller
53+
const updateProgress = (completed: number, currentPercent: number) => {
54+
if (active.current === controller && !controller.signal.aborted) {
55+
setProgress({ completed, currentPercent, total: allowed.length, folderId })
7456
}
75-
updateProgress(index + 1, 0)
7657
}
77-
} finally {
78-
if (active.current === controller) {
79-
active.current = null
80-
setProgress(null)
58+
updateProgress(0, 0)
59+
try {
60+
for (const [index, file] of allowed.entries()) {
61+
if (controller.signal.aborted || !canWriteRef.current) break
62+
try {
63+
await mutateAsync({
64+
projectId,
65+
folderId,
66+
file,
67+
signal: controller.signal,
68+
onProgress: ({ percent }) => updateProgress(index, percent),
69+
})
70+
} catch (error) {
71+
if (controller.signal.aborted) break
72+
const message = getErrorMessage(error, `Unable to upload ${file.name}`)
73+
if (/storage limit/i.test(message)) notifyLimit('storage', message)
74+
else toast.error(message)
75+
}
76+
updateProgress(index + 1, 0)
77+
}
78+
} finally {
79+
if (active.current === controller) {
80+
active.current = null
81+
setProgress(null)
82+
}
8183
}
82-
}
83-
}
84+
},
85+
[projectId, mutateAsync, notifyLimit]
86+
)
8487

8588
const label = progress
8689
? `${progress.completed}/${progress.total}${progress.currentPercent > 0 && progress.currentPercent < 100 ? ` · ${progress.currentPercent}%` : ''}`

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/copyable-markdown.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ function portableWorkspaceResourceMarkdown(
4343
const label = workspaceResourceLabel(data)
4444
if (data.owner?.entityType === 'project') {
4545
return {
46-
markdown: data.id
46+
markdown: data.id?.trim()
4747
? serializePortableChipLink('file', data.id.trim(), label, data.owner)
4848
: label,
4949
hasUnresolvedFile: false,

‎apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ import {
4040
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
4141
import { type McpServer, useMcpToolServers } from '@/hooks/queries/mcp'
4242
import { useWorkspaceProject } from '@/hooks/queries/project-files'
43-
import { useProjects } from '@/hooks/queries/projects'
43+
import { useProjectInventory } from '@/hooks/queries/projects'
4444
import { type SkillDefinition, useSkills } from '@/hooks/queries/skills'
45+
import { useOrderedWorkspacesQuery } from '@/hooks/queries/workspace'
4546
import type { ChatContext } from '@/stores/panel'
4647

4748
/**
@@ -184,17 +185,20 @@ export function usePromptEditor({
184185
const { data: workspaceProject } = useWorkspaceProject(
185186
contextsEnabled && !organizationId ? workspaceId : undefined
186187
)
187-
const { data: projects } = useProjects(contextsEnabled ? (organizationId ?? '') : '')
188+
const { data: projects } = useProjectInventory(organizationId, contextsEnabled)
189+
const { data: workspaces } = useOrderedWorkspacesQuery(contextsEnabled && Boolean(organizationId))
188190
const selectionOwners = useMemo(() => {
189191
const owners: FileOperationOwner[] = []
190192
if (!contextsEnabled) return owners
191193
if (workspaceId) owners.push({ entityType: 'workspace', entityId: workspaceId })
192194
if (organizationId) {
193-
for (const page of projects?.pages ?? []) {
194-
for (const project of page.projects) {
195-
if (project.organizationId === organizationId && !project.archivedAt)
196-
owners.push({ entityType: 'project', entityId: project.id })
197-
}
195+
for (const workspace of workspaces ?? []) {
196+
if (workspace.organizationId === organizationId && workspace.id !== workspaceId)
197+
owners.push({ entityType: 'workspace', entityId: workspace.id })
198+
}
199+
for (const project of projects ?? []) {
200+
if (project.organizationId === organizationId && !project.archivedAt)
201+
owners.push({ entityType: 'project', entityId: project.id })
198202
}
199203
} else if (
200204
workspaceProject &&
@@ -204,7 +208,7 @@ export function usePromptEditor({
204208
owners.push({ entityType: 'project', entityId: workspaceProject.project.id })
205209
}
206210
return owners
207-
}, [contextsEnabled, workspaceId, organizationId, projects, workspaceProject])
211+
}, [contextsEnabled, workspaceId, organizationId, projects, workspaces, workspaceProject])
208212
const { data: queriedSkills = [], isPlaceholderData: skillsAreStale } = useSkills(
209213
contextsEnabled ? workspaceId : ''
210214
)

‎apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-resource-event.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,10 +155,14 @@ export function handleResourceEvent(ctx: StreamLoopContext, parsed: ResourceEven
155155
return
156156
}
157157
if ('owner' in payload.resource && payload.resource.owner?.entityType === 'project') {
158-
if (payload.resource.type !== 'file' || payload.resource.workspaceId) return
158+
if (
159+
(payload.resource.type !== 'file' && payload.resource.type !== 'filefolder') ||
160+
payload.resource.workspaceId
161+
)
162+
return
159163
const owner = payload.resource.owner
160164
void queryClient.invalidateQueries({ queryKey: projectFilesKeys.project(owner.entityId) })
161-
if (payload.op === 'refresh') return
165+
if (payload.op === 'refresh' || payload.resource.type === 'filefolder') return
162166
if (payload.effectId || payload.replay || ctx.deps.options.deferFlushes) {
163167
const chatId = ctx.deps.chatIdRef.current
164168
if (chatId)

‎apps/sim/hooks/queries/utils/file-browser-owner-adapters.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,10 @@ const FILE_BROWSER_ADAPTERS: FileOwnerAdapters<FileBrowserOwnerAdapter> = {
101101
client.invalidateQueries({ queryKey: workspaceFileFolderKeys.workspaceLists(id) }),
102102
client.invalidateQueries({ queryKey: workspaceFilesKeys.storageInfo() }),
103103
...(fileId
104-
? [client.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(id, fileId) })]
104+
? [
105+
client.invalidateQueries({ queryKey: workspaceFilesKeys.contentFile(id, fileId) }),
106+
client.invalidateQueries({ queryKey: workspaceFilesKeys.record(id, fileId) }),
107+
]
105108
: []),
106109
])
107110
},

‎apps/sim/hooks/queries/utils/file-owner-query-adapters.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const FILE_QUERY_ADAPTERS: FileOwnerAdapters<FileOwnerQueryAdapter> = {
9696
{ queryKey: projectFilesKeys.projectLists(id) },
9797
],
9898
invalidationFilters: (id, fileId) => [
99+
{ queryKey: projectFilesKeys.contentFile(id, fileId) },
99100
{ queryKey: projectFilesKeys.record(id, fileId) },
100101
{ queryKey: projectFilesKeys.projectLists(id) },
101102
],

‎apps/sim/hooks/use-invalidation-room.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,10 @@ function createSharedRoomSubscription(
5858
}
5959
const notify = (event: 'changed' | 'joined' | 'denied') => {
6060
for (const group of callbacks.values()) {
61-
const callback = group.values().next().value
62-
if (event === 'denied') callback?.denied?.()
63-
else if (event === 'changed' || callback?.refreshOnJoin) callback?.changed()
61+
for (const callback of group) {
62+
if (event === 'denied') callback.denied?.()
63+
else if (event === 'changed' || callback.refreshOnJoin) callback.changed()
64+
}
6465
}
6566
}
6667
const deny = () => {

0 commit comments

Comments
 (0)