Skip to content

Commit df2308c

Browse files
committed
feat(files): let the user set a file's type from the header dropdown
The file-detail filename dropdown gains a Type submenu offering the text-editable types (nine document formats plus a nested Code group). Picking one swaps the file's extension and its stored contentType in a single write, leaving the bytes untouched, so a file created as untitled.md can become untitled.json and open in the right editor. Renaming previously never touched contentType, so name and type could silently diverge; the retype path keeps them in agreement and the server re-derives the pairing rather than trusting the client.
1 parent aae9ce6 commit df2308c

19 files changed

Lines changed: 1094 additions & 55 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,4 @@ __pycache__/
111111
# `apps/sim/lib/uploads/` — 61 files of tracked source — and silently ignore
112112
# anything added there later.
113113
/uploads
114+
.gstack/
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
5+
import { NextRequest } from 'next/server'
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const { mockPerformRenameWorkspaceFile, mockPerformDeleteWorkspaceFileItems } = vi.hoisted(() => ({
9+
mockPerformRenameWorkspaceFile: vi.fn(),
10+
mockPerformDeleteWorkspaceFileItems: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/workspace-files/orchestration', () => ({
14+
performDeleteWorkspaceFileItems: mockPerformDeleteWorkspaceFileItems,
15+
performRenameWorkspaceFile: mockPerformRenameWorkspaceFile,
16+
}))
17+
18+
vi.mock('@/lib/posthog/server', () => ({
19+
captureServerEvent: vi.fn(),
20+
}))
21+
22+
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
23+
24+
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
25+
const FILE_ID = 'ec28e5d5-898a-48f0-aa6f-2fd7427c9563'
26+
27+
import { captureServerEvent } from '@/lib/posthog/server'
28+
import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/route'
29+
30+
const params = () => ({ params: Promise.resolve({ id: WS, fileId: FILE_ID }) })
31+
32+
const patchRequest = (body: unknown) =>
33+
new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}`, {
34+
method: 'PATCH',
35+
headers: { 'Content-Type': 'application/json' },
36+
body: JSON.stringify(body),
37+
})
38+
39+
const RENAMED_FILE = {
40+
id: FILE_ID,
41+
workspaceId: WS,
42+
name: 'untitled.json',
43+
key: `workspace/${WS}/mock-key`,
44+
path: '/api/files/serve/mock-key?context=workspace',
45+
size: 0,
46+
type: 'application/json',
47+
uploadedBy: 'user-1',
48+
folderId: null,
49+
uploadedAt: new Date('2026-04-13T00:00:00.000Z'),
50+
updatedAt: new Date('2026-04-13T00:00:00.000Z'),
51+
}
52+
53+
describe('PATCH /api/workspaces/[id]/files/[fileId]', () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
authMockFns.mockGetSession.mockResolvedValue({
57+
user: { id: 'user-1', name: 'User One', email: 'u@example.com' },
58+
})
59+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
60+
mockPerformRenameWorkspaceFile.mockResolvedValue({ success: true, file: RENAMED_FILE })
61+
})
62+
63+
describe('auth', () => {
64+
it('returns 401 when unauthenticated', async () => {
65+
authMockFns.mockGetSession.mockResolvedValueOnce(null)
66+
const res = await PATCH(patchRequest({ name: 'notes.md' }), params())
67+
expect(res.status).toBe(401)
68+
})
69+
70+
it('returns 403 for a read-only member', async () => {
71+
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read')
72+
const res = await PATCH(patchRequest({ name: 'notes.md' }), params())
73+
expect(res.status).toBe(403)
74+
expect(mockPerformRenameWorkspaceFile).not.toHaveBeenCalled()
75+
})
76+
})
77+
78+
describe('rename only', () => {
79+
it('forwards the name with no contentType', async () => {
80+
const res = await PATCH(patchRequest({ name: 'notes.md' }), params())
81+
82+
expect(res.status).toBe(200)
83+
expect(mockPerformRenameWorkspaceFile).toHaveBeenCalledWith(
84+
expect.objectContaining({ name: 'notes.md', contentType: undefined })
85+
)
86+
expect(captureServerEvent).toHaveBeenCalledWith(
87+
'user-1',
88+
'file_renamed',
89+
expect.anything(),
90+
expect.anything()
91+
)
92+
})
93+
94+
it('returns 409 on a name conflict', async () => {
95+
mockPerformRenameWorkspaceFile.mockResolvedValueOnce({
96+
success: false,
97+
error: 'A file named "notes.md" already exists',
98+
errorCode: 'conflict',
99+
})
100+
const res = await PATCH(patchRequest({ name: 'notes.md' }), params())
101+
expect(res.status).toBe(409)
102+
})
103+
})
104+
105+
describe('retype', () => {
106+
it('forwards a valid name and contentType pair', async () => {
107+
const res = await PATCH(
108+
patchRequest({ name: 'untitled.json', contentType: 'application/json' }),
109+
params()
110+
)
111+
112+
expect(res.status).toBe(200)
113+
expect(await res.json()).toMatchObject({
114+
success: true,
115+
file: expect.objectContaining({ name: 'untitled.json', type: 'application/json' }),
116+
})
117+
expect(mockPerformRenameWorkspaceFile).toHaveBeenCalledWith(
118+
expect.objectContaining({ name: 'untitled.json', contentType: 'application/json' })
119+
)
120+
})
121+
122+
it('reports a retype separately from a rename', async () => {
123+
await PATCH(
124+
patchRequest({ name: 'untitled.json', contentType: 'application/json' }),
125+
params()
126+
)
127+
128+
expect(captureServerEvent).toHaveBeenCalledWith(
129+
'user-1',
130+
'file_type_changed',
131+
expect.objectContaining({ content_type: 'application/json' }),
132+
expect.anything()
133+
)
134+
})
135+
136+
it('rejects a contentType outside the selectable allowlist', async () => {
137+
const res = await PATCH(
138+
patchRequest({ name: 'installer.exe', contentType: 'application/x-msdownload' }),
139+
params()
140+
)
141+
142+
expect(res.status).toBe(400)
143+
expect(mockPerformRenameWorkspaceFile).not.toHaveBeenCalled()
144+
})
145+
146+
it('rejects a contentType that disagrees with the name extension', async () => {
147+
const res = await PATCH(
148+
patchRequest({ name: 'untitled.json', contentType: 'text/markdown' }),
149+
params()
150+
)
151+
152+
expect(res.status).toBe(400)
153+
expect(mockPerformRenameWorkspaceFile).not.toHaveBeenCalled()
154+
})
155+
156+
it('rejects a contentType paired with an extension no type writes', async () => {
157+
const res = await PATCH(
158+
patchRequest({ name: 'notes.yml', contentType: 'application/x-yaml' }),
159+
params()
160+
)
161+
162+
expect(res.status).toBe(400)
163+
expect(mockPerformRenameWorkspaceFile).not.toHaveBeenCalled()
164+
})
165+
})
166+
})

apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export const PATCH = withRouteHandler(
3737
const parsed = await parseRequest(renameWorkspaceFileContract, request, context)
3838
if (!parsed.success) return parsed.response
3939
const { id: workspaceId, fileId } = parsed.data.params
40-
const { name } = parsed.data.body
40+
const { name, contentType } = parsed.data.body
4141

4242
const userPermission = await getUserEntityPermissions(
4343
session.user.id,
@@ -56,6 +56,7 @@ export const PATCH = withRouteHandler(
5656
fileId,
5757
name,
5858
userId: session.user.id,
59+
contentType,
5960
})
6061
if (!result.success || !result.file) {
6162
return NextResponse.json(
@@ -66,12 +67,21 @@ export const PATCH = withRouteHandler(
6667

6768
logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`)
6869

69-
captureServerEvent(
70-
session.user.id,
71-
'file_renamed',
72-
{ workspace_id: workspaceId },
73-
{ groups: { workspace: workspaceId } }
74-
)
70+
if (contentType) {
71+
captureServerEvent(
72+
session.user.id,
73+
'file_type_changed',
74+
{ workspace_id: workspaceId, content_type: contentType },
75+
{ groups: { workspace: workspaceId } }
76+
)
77+
} else {
78+
captureServerEvent(
79+
session.user.id,
80+
'file_renamed',
81+
{ workspace_id: workspaceId },
82+
{ groups: { workspace: workspaceId } }
83+
)
84+
}
7585
return NextResponse.json({
7686
success: true,
7787
file: result.file,

apps/sim/app/workspace/[workspaceId]/components/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ export {
1313
export type {
1414
BreadcrumbEditing,
1515
BreadcrumbItem,
16+
DropdownMenuOption,
1617
DropdownOption,
18+
DropdownRadioGroup,
19+
DropdownRadioItem,
20+
DropdownSubmenuOption,
1721
ResourceAction,
1822
} from './resource/components/resource-header'
1923
export type {
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
export type {
22
BreadcrumbEditing,
33
BreadcrumbItem,
4+
DropdownMenuOption,
45
DropdownOption,
6+
DropdownRadioGroup,
7+
DropdownRadioItem,
8+
DropdownSubmenuOption,
59
ResourceAction,
610
} from './resource-header'
711
export { ResourceHeader } from './resource-header'

apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ import {
2020
DropdownMenu,
2121
DropdownMenuContent,
2222
DropdownMenuItem,
23+
DropdownMenuRadioGroup,
24+
DropdownMenuRadioItem,
25+
DropdownMenuSub,
26+
DropdownMenuSubContent,
27+
DropdownMenuSubTrigger,
2328
DropdownMenuTrigger,
2429
FloatingTooltip,
2530
POPOVER_ANIMATION_CLASSES,
@@ -44,6 +49,77 @@ export interface DropdownOption {
4449
disabled?: boolean
4550
}
4651

52+
export interface DropdownRadioItem {
53+
/** Selection value, matched against {@link DropdownSubmenuOption.value}. */
54+
id: string
55+
label: string
56+
}
57+
58+
export interface DropdownRadioGroup {
59+
/**
60+
* Nests the group behind a submenu of its own when set. Use it to keep a long tail of options
61+
* (a language list, say) off the top level without losing them.
62+
*/
63+
submenuLabel?: string
64+
items: DropdownRadioItem[]
65+
}
66+
67+
/**
68+
* A dropdown entry that opens a submenu of mutually exclusive choices rather than firing an action.
69+
* Distinguished from {@link DropdownOption} by `groups`, so the two can share one list.
70+
*/
71+
export interface DropdownSubmenuOption {
72+
label: string
73+
icon?: React.ElementType
74+
groups: DropdownRadioGroup[]
75+
/** The currently selected `id`, or undefined when the value is not one of the offered choices. */
76+
value?: string
77+
onValueChange: (id: string) => void
78+
disabled?: boolean
79+
}
80+
81+
export type DropdownMenuOption = DropdownOption | DropdownSubmenuOption
82+
83+
function isSubmenuOption(option: DropdownMenuOption): option is DropdownSubmenuOption {
84+
return 'groups' in option
85+
}
86+
87+
interface DropdownRadioGroupContentProps {
88+
group: DropdownRadioGroup
89+
value?: string
90+
onValueChange: (id: string) => void
91+
}
92+
93+
/**
94+
* One radio group inside a {@link DropdownSubmenuOption}, nested behind its own submenu when the
95+
* group carries a `submenuLabel`. Every group is handed the same `value`; only the one that owns it
96+
* renders a selected indicator, which is what lets the selection read correctly across groups.
97+
*/
98+
function DropdownRadioGroupContent({
99+
group,
100+
value,
101+
onValueChange,
102+
}: DropdownRadioGroupContentProps) {
103+
const items = (
104+
<DropdownMenuRadioGroup value={value} onValueChange={onValueChange}>
105+
{group.items.map((item) => (
106+
<DropdownMenuRadioItem key={item.id} value={item.id}>
107+
{item.label}
108+
</DropdownMenuRadioItem>
109+
))}
110+
</DropdownMenuRadioGroup>
111+
)
112+
113+
if (!group.submenuLabel) return items
114+
115+
return (
116+
<DropdownMenuSub>
117+
<DropdownMenuSubTrigger>{group.submenuLabel}</DropdownMenuSubTrigger>
118+
<DropdownMenuSubContent>{items}</DropdownMenuSubContent>
119+
</DropdownMenuSub>
120+
)
121+
}
122+
47123
export interface BreadcrumbEditing {
48124
isEditing: boolean
49125
value: string
@@ -63,7 +139,7 @@ export interface BreadcrumbItem {
63139
label: string
64140
icon?: React.ElementType
65141
onClick?: () => void
66-
dropdownItems?: DropdownOption[]
142+
dropdownItems?: DropdownMenuOption[]
67143
editing?: BreadcrumbEditing
68144
/**
69145
* Marks a non-navigable trailing crumb (e.g. "New Chunk", "Loading...") so the
@@ -266,7 +342,7 @@ interface BreadcrumbSegmentProps {
266342
icon?: React.ElementType
267343
label: string
268344
onClick?: () => void
269-
dropdownItems?: DropdownOption[]
345+
dropdownItems?: DropdownMenuOption[]
270346
editing?: BreadcrumbEditing
271347
className?: string
272348
}
@@ -327,6 +403,26 @@ const BreadcrumbSegment = memo(function BreadcrumbSegment({
327403
<DropdownMenuContent align='start'>
328404
{dropdownItems.map((item) => {
329405
const ItemIcon = item.icon
406+
if (isSubmenuOption(item)) {
407+
return (
408+
<DropdownMenuSub key={item.label}>
409+
<DropdownMenuSubTrigger disabled={item.disabled}>
410+
{ItemIcon && <ItemIcon className='size-[14px]' />}
411+
{item.label}
412+
</DropdownMenuSubTrigger>
413+
<DropdownMenuSubContent>
414+
{item.groups.map((group) => (
415+
<DropdownRadioGroupContent
416+
key={group.submenuLabel ?? 'default'}
417+
group={group}
418+
value={item.value}
419+
onValueChange={item.onValueChange}
420+
/>
421+
))}
422+
</DropdownMenuSubContent>
423+
</DropdownMenuSub>
424+
)
425+
}
330426
return (
331427
<DropdownMenuItem key={item.label} onClick={item.onClick} disabled={item.disabled}>
332428
{ItemIcon && <ItemIcon className='size-[14px]' />}

0 commit comments

Comments
 (0)