Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions apps/sim/app/api/pinned-items/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,23 @@ describe('Pinned Items API', () => {
)
})

it('accepts folder as a pinnable resourceType', async () => {
mockReturning.mockReturnValueOnce([
{ ...pinnedWorkflowRow, resourceType: 'folder', resourceId: 'folder-1' },
])

const response = await POST(
createMockRequest('POST', { ...body, resourceType: 'folder', resourceId: 'folder-1' })
)

expect(response.status).toBe(201)
const data = await response.json()
expect(data.pinnedItem).toMatchObject({ resourceType: 'folder', resourceId: 'folder-1' })
expect(mockValues).toHaveBeenCalledWith(
expect.objectContaining({ resourceType: 'folder', resourceId: 'folder-1' })
)
})

it('returns 404 when the resource is not in the workspace', async () => {
mockLimit.mockReturnValueOnce([])

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,6 @@ export interface ResourceCell {
* resting cell exactly (same gap, weight, icon size).
*/
editing?: ResourceCellEditing
/**
* Trailing node pinned to the far edge of the cell, after the label. Use this for
* per-row affordances (e.g. a pin toggle) so the canonical icon + truncating-label
* rendering is reused rather than re-derived in a `content` node. Suppressed while
* the cell is in rename mode, where the input owns the full width.
*
* The row sets `group`, so an adornment may use `group-hover:` to reveal itself.
*/
endAdornment?: ReactNode
}

export interface ResourceRow {
Expand Down Expand Up @@ -497,16 +488,9 @@ interface CellContentProps {
label: string
content?: ReactNode
editing?: ResourceCellEditing
endAdornment?: ReactNode
}

const CellContent = memo(function CellContent({
icon,
label,
content,
editing,
endAdornment,
}: CellContentProps) {
const CellContent = memo(function CellContent({ icon, label, content, editing }: CellContentProps) {
if (editing) {
return (
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
Expand All @@ -522,19 +506,12 @@ const CellContent = memo(function CellContent({
)
}
if (content) return <>{content}</>
const body = (
return (
<span className={cn('flex min-w-0 items-center', chipContentGap)}>
{icon && <span className={cellIconNodeClass}>{icon}</span>}
<FloatingOverflowText label={label} className={cn('block', chipContentLabelClass)} />
</span>
)
if (!endAdornment) return body
return (
<span className='flex min-w-0 flex-1 items-center justify-between'>
{body}
{endAdornment}
</span>
)
})

interface DataRowProps {
Expand Down Expand Up @@ -661,7 +638,7 @@ const DataRow = memo(function DataRow({
data-resource-row
data-row-id={row.id}
className={cn(
'group grid w-full transition-colors',
'grid w-full transition-colors',
isWindowed && 'absolute top-0 left-0',
!isAnyDragActive && 'hover-hover:bg-[var(--surface-3)]',
onRowClick && 'cursor-pointer',
Expand Down Expand Up @@ -704,7 +681,6 @@ const DataRow = memo(function DataRow({
label={cell?.label || EMPTY_CELL_PLACEHOLDER}
content={cell?.content}
editing={cell?.editing}
endAdornment={cell?.endAdornment}
/>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
FolderInput,
Pencil,
} from '@sim/emcn'
import { Download, Link, Trash } from '@sim/emcn/icons'
import { Download, Link, Pin, Trash } from '@sim/emcn/icons'
import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/files/move-options'
import { renderMoveOption } from '@/app/workspace/[workspaceId]/files/move-options'

Expand All @@ -29,6 +29,9 @@ interface FileRowContextMenuProps {
onDelete: () => void
onMove?: (optionValue: string) => void
onShare?: () => void
onTogglePin: () => void
/** Pin state of the right-clicked row, driving the Pin/Unpin label. */
pinned: boolean
moveOptions?: MoveOptionNode[]
canEdit: boolean
selectedCount: number
Expand All @@ -44,6 +47,8 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
onDelete,
onMove,
onShare,
onTogglePin,
pinned,
moveOptions,
canEdit,
selectedCount,
Expand Down Expand Up @@ -78,6 +83,12 @@ export const FileRowContextMenu = memo(function FileRowContextMenu({
{isMultiSelect ? `Download ${selectedCount} items` : 'Download'}
</DropdownMenuItem>
)}
{!isMultiSelect && (
<DropdownMenuItem onSelect={onTogglePin}>
<Pin />
{pinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
)}
{canEdit && (
<>
<DropdownMenuSeparator />
Expand Down
55 changes: 37 additions & 18 deletions apps/sim/app/workspace/[workspaceId]/files/files.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { usePostHog } from 'posthog-js/react'
import { getDocumentIcon } from '@/components/icons/document-icons'
import { PinButton } from '@/components/pin-button'
import { useLimitUpgradeToast } from '@/lib/billing/client'
import { captureEvent } from '@/lib/posthog/client'
import { triggerArchiveDownload, triggerFileDownload } from '@/lib/uploads/client/download'
Expand Down Expand Up @@ -85,7 +84,7 @@ import {
} from '@/app/workspace/[workspaceId]/files/search-params'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
import { usePinnedIds } from '@/hooks/queries/pinned-items'
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
import { useWorkspaceMembersQuery, type WorkspaceMember } from '@/hooks/queries/workspace'
import {
useBulkArchiveWorkspaceFileItems,
Expand Down Expand Up @@ -215,6 +214,10 @@ export function Files() {
const { data: folders = EMPTY_WORKSPACE_FILE_FOLDERS } = useWorkspaceFileFolders(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const pinnedFileIds = usePinnedIds(workspaceId, 'file')
// Folders pin under their own resource type, so their pinned set is a separate query.
const pinnedFolderIds = usePinnedIds(workspaceId, 'folder')
const pinItem = usePinItem()
const unpinItem = useUnpinItem()
const membersById = useMemo(() => {
const map = new Map<string, WorkspaceMember>()
for (const member of members ?? []) map.set(member.userId, member)
Expand Down Expand Up @@ -398,6 +401,12 @@ export function Files() {
const col = activeSort?.column ?? 'name'
const dir = activeSort?.direction ?? 'asc'
return [...searched].sort((a, b) => {
// Pinned folders float to the top of every sort/direction — pinning is a
// user-declared priority, not another sort key to be inverted by `desc`.
const aPinned = pinnedFolderIds.has(a.id)
const bPinned = pinnedFolderIds.has(b.id)
if (aPinned !== bPinned) return aPinned ? -1 : 1

let cmp = 0
if (col === 'updated') {
cmp = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
Expand All @@ -408,7 +417,7 @@ export function Files() {
}
return dir === 'asc' ? cmp : -cmp
})
}, [folders, currentFolderId, debouncedSearchTerm, activeSort])
}, [folders, currentFolderId, debouncedSearchTerm, activeSort, pinnedFolderIds])

const filteredFiles = useMemo(() => {
const needle = debouncedSearchTerm.trim().toLowerCase()
Expand Down Expand Up @@ -521,15 +530,6 @@ export function Files() {
name: {
icon: <Icon className='size-[14px]' />,
label: file.name,
endAdornment: (
<PinButton
workspaceId={workspaceId}
resourceType='file'
resourceId={file.id}
pinned={pinnedFileIds.has(file.id)}
className='ml-2'
/>
),
},
size: {
label: formatFileSize(file.size, { includeBytes: true }),
Expand All @@ -547,7 +547,7 @@ export function Files() {
})

return [...folderRows, ...fileRows]
}, [visibleFolders, filteredFiles, membersById, folderSizeMap, workspaceId, pinnedFileIds])
}, [visibleFolders, filteredFiles, membersById, folderSizeMap])

const rows: ResourceRow[] = useMemo(() => {
if (!listRename.editingId) return baseRows
Expand Down Expand Up @@ -1365,6 +1365,17 @@ export function Files() {
closeContextMenu()
}, [selectedRowIds, handleBulkDelete, closeContextMenu])

const handleContextMenuTogglePin = useCallback(() => {
const item = contextMenuItemRef.current
if (!item) return
const resourceType = item.kind === 'folder' ? 'folder' : 'file'
const pinned =
item.kind === 'folder' ? pinnedFolderIds.has(item.id) : pinnedFileIds.has(item.id)
const mutation = pinned ? unpinItem : pinItem
mutation.mutate({ workspaceId, resourceType, resourceId: item.id })
closeContextMenu()
}, [workspaceId, pinnedFolderIds, pinnedFileIds, closeContextMenu])

const handleContextMenuMove = useCallback(
async (optionValue: string) => {
const targetFolderId = optionValue === '__root__' ? null : optionValue
Expand Down Expand Up @@ -1985,6 +1996,16 @@ export function Files() {
)
}

/**
* Read off the same ref the context-menu handlers use, so the menu's Pin/Unpin label
* describes the row that was right-clicked. Opening the menu is a state change, so
* this re-reads on the render that shows it.
*/
const contextMenuItem = contextMenuItemRef.current
const isContextMenuItemPinned = contextMenuItem
? (contextMenuItem.kind === 'folder' ? pinnedFolderIds : pinnedFileIds).has(contextMenuItem.id)
: false

return (
<div
className='relative flex h-full flex-col overflow-hidden'
Expand Down Expand Up @@ -2064,11 +2085,9 @@ export function Files() {
onRename={handleContextMenuRename}
onDelete={handleContextMenuDelete}
onMove={handleContextMenuMove}
onShare={
canEdit && contextMenuItemRef.current?.kind === 'file'
? handleContextMenuShare
: undefined
}
onShare={canEdit && contextMenuItem?.kind === 'file' ? handleContextMenuShare : undefined}
onTogglePin={handleContextMenuTogglePin}
pinned={isContextMenuItemPinned}
moveOptions={contextMenuMoveOptions}
canEdit={canEdit}
selectedCount={selectedRowIds.size}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@sim/emcn'
import { Duplicate, Pencil, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'
import { Duplicate, Pencil, Pin, SquareArrowUpRight, TagIcon, Trash } from '@sim/emcn/icons'

interface KnowledgeBaseContextMenuProps {
isOpen: boolean
Expand All @@ -17,6 +17,9 @@ interface KnowledgeBaseContextMenuProps {
onOpenInNewTab?: () => void
onViewTags?: () => void
onCopyId?: () => void
onTogglePin?: () => void
/** Pin state of the right-clicked base, driving the Pin/Unpin label. */
pinned?: boolean
onEdit?: () => void
onDelete?: () => void
showOpenInNewTab?: boolean
Expand All @@ -38,6 +41,8 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
onOpenInNewTab,
onViewTags,
onCopyId,
onTogglePin,
pinned = false,
onEdit,
onDelete,
showOpenInNewTab = true,
Expand All @@ -48,7 +53,7 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
disableDelete = false,
}: KnowledgeBaseContextMenuProps) {
const hasNavigationSection = showOpenInNewTab && !!onOpenInNewTab
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId
const hasInfoSection = (showViewTags && !!onViewTags) || !!onCopyId || !!onTogglePin
const hasEditSection = showEdit && !!onEdit
const hasDestructiveSection = showDelete && !!onDelete

Expand Down Expand Up @@ -96,6 +101,12 @@ export const KnowledgeBaseContextMenu = memo(function KnowledgeBaseContextMenu({
Copy ID
</DropdownMenuItem>
)}
{onTogglePin && (
<DropdownMenuItem onSelect={onTogglePin}>
<Pin />
{pinned ? 'Unpin' : 'Pin'}
</DropdownMenuItem>
)}
{hasInfoSection && (hasEditSection || hasDestructiveSection) && <DropdownMenuSeparator />}

{showEdit && onEdit && (
Expand Down
26 changes: 14 additions & 12 deletions apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Database } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
import { PinButton } from '@/components/pin-button'
import type { KnowledgeBaseData } from '@/lib/knowledge/types'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import type {
Expand Down Expand Up @@ -44,7 +43,7 @@ import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sideb
import { CONNECTOR_META_REGISTRY } from '@/connectors/registry'
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/kb/knowledge'
import { usePinnedIds } from '@/hooks/queries/pinned-items'
import { usePinItem, usePinnedIds, useUnpinItem } from '@/hooks/queries/pinned-items'
import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace'
import { useDebounce } from '@/hooks/use-debounce'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
Expand Down Expand Up @@ -146,6 +145,8 @@ export function Knowledge() {
const { knowledgeBases, error } = useKnowledgeBasesList(workspaceId)
const { data: members } = useWorkspaceMembersQuery(workspaceId)
const pinnedBaseIds = usePinnedIds(workspaceId, 'knowledge_base')
const pinItem = usePinItem()
const unpinItem = useUnpinItem()

if (error) {
logger.error('Failed to load knowledge bases:', error)
Expand Down Expand Up @@ -349,15 +350,6 @@ export function Knowledge() {
name: {
icon: KNOWLEDGE_BASE_ICON,
label: kb.name,
endAdornment: (
<PinButton
workspaceId={workspaceId}
resourceType='knowledge_base'
resourceId={kb.id}
pinned={pinnedBaseIds.has(kb.id)}
className='ml-2'
/>
),
},
documents: {
label: String(kbWithCount.docCount || 0),
Expand All @@ -372,7 +364,7 @@ export function Knowledge() {
},
}
}),
[processedKBs, members, workspaceId, pinnedBaseIds]
[processedKBs, members]
)

const handleRowClick = useCallback(
Expand Down Expand Up @@ -437,6 +429,14 @@ export function Knowledge() {
setIsEditModalOpen(true)
}, [])

const handleTogglePin = useCallback(() => {
const kb = activeKnowledgeBaseRef.current
if (!kb) return
const mutation = pinnedBaseIds.has(kb.id) ? unpinItem : pinItem
mutation.mutate({ workspaceId, resourceType: 'knowledge_base', resourceId: kb.id })
closeRowContextMenu()
}, [workspaceId, pinnedBaseIds, closeRowContextMenu])

const handleDelete = useCallback(() => {
setIsDeleteModalOpen(true)
}, [])
Expand Down Expand Up @@ -645,6 +645,8 @@ export function Knowledge() {
onOpenInNewTab={handleOpenInNewTab}
onViewTags={handleViewTags}
onCopyId={handleCopyId}
onTogglePin={handleTogglePin}
pinned={pinnedBaseIds.has(activeKnowledgeBase.id)}
onEdit={handleEdit}
onDelete={handleDelete}
showOpenInNewTab
Expand Down
Loading
Loading