Skip to content

Commit d785434

Browse files
committed
fix(mothership): render saved file folder resources
1 parent b42f60b commit d785434

3 files changed

Lines changed: 230 additions & 0 deletions

File tree

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/** @vitest-environment jsdom */
2+
import { act } from 'react'
3+
import { createRoot } from 'react-dom/client'
4+
import { renderToStaticMarkup } from 'react-dom/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { EmbeddedFileFolder } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-file-folder'
7+
8+
const { useFolders, useFiles, state } = vi.hoisted(() => {
9+
const state = {
10+
pending: false,
11+
placeholder: false,
12+
error: null as Error | null,
13+
folders: [
14+
{ id: 'parent', name: 'Reports', parentId: null },
15+
{ id: 'child', name: 'Client documents', parentId: 'parent' },
16+
{ id: 'sibling', name: 'Unrelated folder', parentId: null },
17+
],
18+
files: [
19+
{ id: 'document', name: 'Invoice.md', folderId: 'parent' },
20+
{ id: 'other', name: 'Unrelated document', folderId: 'sibling' },
21+
],
22+
}
23+
return {
24+
state,
25+
useFolders: vi.fn(() => ({
26+
data: state.folders,
27+
isPending: false,
28+
isPlaceholderData: false,
29+
error: null,
30+
refetch: vi.fn(),
31+
})),
32+
useFiles: vi.fn(() => ({
33+
data: state.files,
34+
isPending: state.pending,
35+
isPlaceholderData: state.placeholder,
36+
error: state.error,
37+
refetch: vi.fn(),
38+
})),
39+
}
40+
})
41+
vi.mock('@/hooks/queries/workspace-file-folders', () => ({ useWorkspaceFileFolders: useFolders }))
42+
vi.mock('@/hooks/queries/workspace-files', () => ({ useWorkspaceFiles: useFiles }))
43+
44+
const render = (folderId = 'parent') =>
45+
renderToStaticMarkup(
46+
<EmbeddedFileFolder workspaceId='workspace' folderId={folderId} onOpen={vi.fn()} />
47+
)
48+
49+
describe('EmbeddedFileFolder', () => {
50+
beforeEach(() => {
51+
vi.clearAllMocks()
52+
state.pending = false
53+
state.placeholder = false
54+
state.error = null
55+
})
56+
57+
it('shows direct child folders and files without including siblings', () => {
58+
const markup = render()
59+
expect(markup).toContain('Reports')
60+
expect(markup).toContain('Client documents')
61+
expect(markup).toContain('Invoice.md')
62+
expect(markup).not.toContain('Unrelated')
63+
expect(markup).toContain('Open folder')
64+
expect(useFolders).toHaveBeenCalledWith('workspace')
65+
expect(useFiles).toHaveBeenCalledWith('workspace')
66+
})
67+
68+
it.each(['pending', 'placeholder'] as const)(
69+
'does not render an empty or stale folder while files are %s',
70+
(key) => {
71+
state[key] = true
72+
const markup = render()
73+
expect(markup).toContain('Loading folder')
74+
expect(markup).not.toContain('Invoice.md')
75+
expect(markup).not.toContain('This folder is empty')
76+
}
77+
)
78+
79+
it('keeps a failed read distinct from an empty folder', () => {
80+
state.error = new Error('private diagnostic')
81+
const markup = render()
82+
expect(markup).toContain('Unable to load this folder')
83+
expect(markup).toContain('Try again')
84+
expect(markup).not.toContain('private diagnostic')
85+
expect(markup).not.toContain('Invoice.md')
86+
})
87+
88+
it('distinguishes an empty existing folder from a missing folder', () => {
89+
expect(render('child')).toContain('This folder is empty')
90+
expect(render('missing')).toContain('Folder not found')
91+
})
92+
93+
it('opens the exact selected folder or file through normal workspace destinations', () => {
94+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
95+
const container = document.createElement('div')
96+
document.body.appendChild(container)
97+
const root = createRoot(container)
98+
const onOpen = vi.fn()
99+
try {
100+
act(() =>
101+
root.render(
102+
<EmbeddedFileFolder workspaceId='workspace' folderId='parent' onOpen={onOpen} />
103+
)
104+
)
105+
for (const name of ['Open folder', 'Client documents', 'Invoice.md']) {
106+
const button = Array.from(container.querySelectorAll('button')).find(
107+
(element) => element.textContent === name
108+
)
109+
expect(button).toBeDefined()
110+
act(() => button!.click())
111+
}
112+
expect(onOpen.mock.calls).toEqual([
113+
['/workspace/workspace/files?folderId=parent'],
114+
['/workspace/workspace/files?folderId=child'],
115+
['/workspace/workspace/files/document'],
116+
])
117+
} finally {
118+
act(() => root.unmount())
119+
container.remove()
120+
vi.unstubAllGlobals()
121+
}
122+
})
123+
})
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
'use client'
2+
3+
import { Button, OverflowText } from '@sim/emcn'
4+
import { File as FileIcon, Folder as FolderIcon } from '@sim/emcn/icons'
5+
import { folderedResourceListHref } from '@/app/workspace/[workspaceId]/components/folders/foldered-resources'
6+
import { useWorkspaceFileFolders } from '@/hooks/queries/workspace-file-folders'
7+
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
8+
9+
interface EmbeddedFileFolderProps {
10+
workspaceId: string
11+
folderId: string
12+
onOpen: (href: string) => void
13+
}
14+
15+
/** Uses the ordinary file browser queries and destinations inside the chat resource panel. */
16+
export function EmbeddedFileFolder({ workspaceId, folderId, onOpen }: EmbeddedFileFolderProps) {
17+
const folders = useWorkspaceFileFolders(workspaceId)
18+
const files = useWorkspaceFiles(workspaceId)
19+
20+
if (folders.error || files.error) {
21+
return (
22+
<div role='alert' className='flex h-full flex-col items-center justify-center gap-3 p-6'>
23+
<p>Unable to load this folder</p>
24+
<Button
25+
onClick={() => {
26+
void folders.refetch()
27+
void files.refetch()
28+
}}
29+
>
30+
Try again
31+
</Button>
32+
</div>
33+
)
34+
}
35+
if (
36+
folders.isPending ||
37+
files.isPending ||
38+
folders.isPlaceholderData ||
39+
files.isPlaceholderData
40+
) {
41+
return (
42+
<div role='status' className='p-6 text-[var(--text-muted)]'>
43+
Loading folder…
44+
</div>
45+
)
46+
}
47+
48+
const folder = folders.data?.find((item) => item.id === folderId)
49+
if (!folder) {
50+
return <div className='p-6 text-[var(--text-muted)]'>Folder not found</div>
51+
}
52+
const children = folders.data?.filter((item) => item.parentId === folderId) ?? []
53+
const documents = files.data?.filter((file) => file.folderId === folderId) ?? []
54+
const itemClass =
55+
'flex items-center gap-2 rounded-[6px] px-3 py-2 text-left transition-colors hover:bg-[var(--surface-4)]'
56+
57+
return (
58+
<div className='flex h-full flex-col overflow-y-auto p-6'>
59+
<div className='mb-4 flex items-center justify-between gap-3'>
60+
<h2 className='text-[16px] text-[var(--text-primary)]'>{folder.name}</h2>
61+
<Button onClick={() => onOpen(folderedResourceListHref('file', workspaceId, folderId))}>
62+
Open folder
63+
</Button>
64+
</div>
65+
{children.length === 0 && documents.length === 0 ? (
66+
<p className='text-[13px] text-[var(--text-muted)]'>This folder is empty</p>
67+
) : (
68+
<div className='flex flex-col gap-1'>
69+
{children.map((child) => (
70+
<button
71+
key={child.id}
72+
type='button'
73+
className={itemClass}
74+
onClick={() => onOpen(folderedResourceListHref('file', workspaceId, child.id))}
75+
>
76+
<FolderIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
77+
<OverflowText label={child.name} className='text-[var(--text-primary)] text-small' />
78+
</button>
79+
))}
80+
{documents.map((file) => (
81+
<button
82+
key={file.id}
83+
type='button'
84+
className={itemClass}
85+
onClick={() => onOpen(`/workspace/${workspaceId}/files/${file.id}`)}
86+
>
87+
<FileIcon className='size-[14px] flex-shrink-0 text-[var(--text-icon)]' />
88+
<OverflowText label={file.name} className='text-[var(--text-primary)] text-small' />
89+
</button>
90+
))}
91+
</div>
92+
)}
93+
</div>
94+
)
95+
}

‎apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
} from '@/app/workspace/[workspaceId]/files/components/file-viewer'
3232
import type { BrowserPanelOverlayController } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion'
3333
import { BrowserSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session'
34+
import { EmbeddedFileFolder } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/embedded-file-folder'
3435
import { GenericResourceContent } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/generic-resource-content'
3536
import { TerminalSession } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/terminal-session/terminal-session'
3637
import {
@@ -177,6 +178,7 @@ export const ResourceContent = memo(function ResourceContent({
177178
visible = true,
178179
onBrowserOverlayControllerChange,
179180
}: ResourceContentProps) {
181+
const openInternalLink = useOpenInternalLink()
180182
const reportTableView = useCallback(
181183
(context: MothershipTableViewContext) => {
182184
onTableViewContextChange?.(resource.id, context)
@@ -298,6 +300,16 @@ export const ResourceContent = memo(function ResourceContent({
298300
case 'folder':
299301
return <EmbeddedFolder key={resource.id} workspaceId={workspaceId} folderId={resource.id} />
300302

303+
case 'filefolder':
304+
return (
305+
<EmbeddedFileFolder
306+
key={resource.id}
307+
workspaceId={workspaceId}
308+
folderId={resource.id}
309+
onOpen={openInternalLink}
310+
/>
311+
)
312+
301313
case 'log':
302314
return (
303315
<EmbeddedLog

0 commit comments

Comments
 (0)