diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 7bfc3ed967e..e781191f6f9 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -188,7 +188,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { --border-1: #e0e0e0; /* stronger border */ --surface-6: #e5e5e5; /* popovers, elevated surfaces */ --surface-7: #d9d9d9; - --surface-active: #ececec; /* hover/active state */ + --surface-hover: #f2f2f2; /* hover state */ + --surface-active: #ececec; /* active/selected state */ --workflow-edge: #e0e0e0; /* workflow handles/edges - matches border-1 */ @@ -342,7 +343,8 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { --border-1: #3d3d3d; --surface-6: #454545; --surface-7: #505050; - --surface-active: #2c2c2c; /* hover/active state */ + --surface-hover: #262626; /* hover state */ + --surface-active: #2c2c2c; /* active/selected state */ --workflow-edge: #454545; /* workflow handles/edges - same as surface-6 in dark */ @@ -501,9 +503,6 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn { caret-color: var(--text-primary); } - body { - @apply antialiased; - } ::-webkit-scrollbar { width: var(--scrollbar-size); height: var(--scrollbar-size); diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx index 63b3ee1cddc..d133301872f 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/owner-cell/owner-cell.tsx @@ -1,7 +1,13 @@ +import { memo } from 'react' import type { ResourceCell } from '@/app/workspace/[workspaceId]/components/resource/resource' import type { WorkspaceMember } from '@/hooks/queries/workspace' -function OwnerAvatar({ name, image }: { name: string; image: string | null }) { +interface OwnerAvatarProps { + name: string + image: string | null +} + +const OwnerAvatar = memo(function OwnerAvatar({ name, image }: OwnerAvatarProps) { if (image) { return ( ) -} +}) /** * Resolves a user ID into a ResourceCell with an avatar icon and display name. diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 4a63ce8ed00..68c245b1dd7 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -11,6 +11,8 @@ import { import { cn } from '@/lib/core/utils/cn' import { InlineRenameInput } from '@/app/workspace/[workspaceId]/components/inline-rename-input' +const HEADER_PLUS_ICON = + export interface DropdownOption { label: string icon?: React.ElementType @@ -122,7 +124,7 @@ export const ResourceHeader = memo(function ResourceHeader({ variant='subtle' className='px-2 py-1 text-caption' > - + {HEADER_PLUS_ICON} {create.label} )} @@ -132,19 +134,21 @@ export const ResourceHeader = memo(function ResourceHeader({ ) }) -function BreadcrumbSegment({ - icon: Icon, - label, - onClick, - dropdownItems, - editing, -}: { +interface BreadcrumbSegmentProps { icon?: React.ElementType label: string onClick?: () => void dropdownItems?: DropdownOption[] editing?: BreadcrumbEditing -}) { +} + +const BreadcrumbSegment = memo(function BreadcrumbSegment({ + icon: Icon, + label, + onClick, + dropdownItems, + editing, +}: BreadcrumbSegmentProps) { if (editing?.isEditing) { return ( @@ -203,4 +207,4 @@ function BreadcrumbSegment({ {content} ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx index 5749e243e41..d16f83dc4fe 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-options-bar/resource-options-bar.tsx @@ -1,4 +1,4 @@ -import { memo, type ReactNode } from 'react' +import { memo, type ReactNode, useCallback, useRef, useState } from 'react' import * as PopoverPrimitive from '@radix-ui/react-popover' import { ArrowDown, @@ -16,6 +16,12 @@ import { } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' +const SEARCH_ICON = ( + +) +const FILTER_ICON = +const SORT_ICON = + type SortDirection = 'asc' | 'desc' export interface ColumnOption { @@ -79,56 +85,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ return (
- {search && ( -
- -
- {search.tags?.map((tag, i) => ( - - ))} - search.onChange(e.target.value)} - onKeyDown={search.onKeyDown} - onFocus={search.onFocus} - onBlur={search.onBlur} - placeholder={search.tags?.length ? '' : (search.placeholder ?? 'Search...')} - className='min-w-[80px] flex-1 bg-transparent py-1 text-[var(--text-secondary)] text-caption outline-none placeholder:text-[var(--text-subtle)]' - /> -
- {search.tags?.length || search.value ? ( - - ) : null} - {search.dropdown && ( -
- {search.dropdown} -
- )} -
- )} + {search && }
{extras} {filterTags?.map((tag) => ( @@ -146,7 +103,7 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ @@ -170,14 +127,94 @@ export const ResourceOptionsBar = memo(function ResourceOptionsBar({ ) }) -function SortDropdown({ config }: { config: SortConfig }) { +const SearchSection = memo(function SearchSection({ search }: { search: SearchConfig }) { + const [localValue, setLocalValue] = useState(search.value) + + const lastReportedRef = useRef(search.value) + + if (search.value !== lastReportedRef.current) { + setLocalValue(search.value) + lastReportedRef.current = search.value + } + + const handleInputChange = useCallback( + (e: React.ChangeEvent) => { + const next = e.target.value + setLocalValue(next) + search.onChange(next) + }, + [search.onChange] + ) + + const handleClearAll = useCallback(() => { + setLocalValue('') + lastReportedRef.current = '' + if (search.onClearAll) { + search.onClearAll() + } else { + search.onChange('') + } + }, [search.onClearAll, search.onChange]) + + return ( +
+ {SEARCH_ICON} +
+ {search.tags?.map((tag, i) => ( + + ))} + +
+ {search.tags?.length || localValue ? ( + + ) : null} + {search.dropdown && ( +
+ {search.dropdown} +
+ )} +
+ ) +}) + +const SortDropdown = memo(function SortDropdown({ config }: { config: SortConfig }) { const { options, active, onSort, onClear } = config return ( @@ -218,4 +255,4 @@ function SortDropdown({ config }: { config: SortConfig }) { ) -} +}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx index 6882ac89a6d..32792f1f367 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/resource.tsx @@ -8,6 +8,8 @@ import { ResourceHeader } from './components/resource-header' import type { FilterTag, SearchConfig, SortConfig } from './components/resource-options-bar' import { ResourceOptionsBar } from './components/resource-options-bar' +const CREATE_ROW_PLUS_ICON = + export interface ResourceColumn { id: string header: string @@ -69,11 +71,13 @@ interface ResourceProps { const EMPTY_CELL_PLACEHOLDER = '- - -' const SKELETON_ROW_COUNT = 5 +const stopPropagation = (e: React.MouseEvent) => e.stopPropagation() + /** * Shared page shell for resource list pages (tables, files, knowledge, schedules, logs). * Renders the header, toolbar with search, and a data table from column/row definitions. */ -export function Resource({ +export const Resource = memo(function Resource({ icon, title, breadcrumbs, @@ -135,7 +139,7 @@ export function Resource({ />
) -} +}) export interface ResourceTableProps { columns: ResourceColumn[] @@ -229,6 +233,13 @@ export const ResourceTable = memo(function ResourceTable({ const hasCheckbox = selectable != null const totalColSpan = columns.length + (hasCheckbox ? 1 : 0) + const handleSelectAll = useCallback( + (checked: boolean | 'indeterminate') => { + selectable?.onSelectAll(checked as boolean) + }, + [selectable] + ) + if (isLoading) { return ( selectable.onSelectAll(checked as boolean)} + onCheckedChange={handleSelectAll} disabled={selectable.disabled} aria-label='Select all' /> @@ -306,68 +317,20 @@ export const ResourceTable = memo(function ResourceTable({ - {displayRows.map((row) => { - const isSelected = selectable?.selectedIds.has(row.id) ?? false - return ( - onRowClick?.(row.id)} - onMouseEnter={onRowHover ? () => onRowHover(row.id) : undefined} - onContextMenu={(e) => onRowContextMenu?.(e, row.id)} - > - {hasCheckbox && ( - - )} - {columns.map((col, colIdx) => { - const cell = row.cells[col.id] - return ( - - ) - })} - - ) - })} - {create && ( - - - - )} + {displayRows.map((row) => ( + + ))} + {create && }
- - selectable.onSelectRow(row.id, checked as boolean) - } - disabled={selectable.disabled} - aria-label='Select row' - onClick={(e) => e.stopPropagation()} - /> - - -
- - - {create.label} - -
{hasMore && ( @@ -390,7 +353,7 @@ export const ResourceTable = memo(function ResourceTable({ ) }) -function Pagination({ +const Pagination = memo(function Pagination({ currentPage, totalPages, onPageChange, @@ -447,10 +410,17 @@ function Pagination({
) +}) + +interface CellContentProps { + icon?: ReactNode + label: string + content?: ReactNode + primary?: boolean } -function CellContent({ cell, primary }: { cell: ResourceCell; primary?: boolean }) { - if (cell.content) return <>{cell.content} +const CellContent = memo(function CellContent({ icon, label, content, primary }: CellContentProps) { + if (content) return <>{content} return ( - {cell.icon && {cell.icon}} - {cell.label} + {icon && {icon}} + {label} ) +}) + +interface DataRowProps { + row: ResourceRow + columns: ResourceColumn[] + selectedRowId?: string | null + selectable?: SelectableConfig + onRowClick?: (rowId: string) => void + onRowHover?: (rowId: string) => void + onRowContextMenu?: (e: React.MouseEvent, rowId: string) => void + hasCheckbox: boolean } -function ResourceColGroup({ +const DataRow = memo(function DataRow({ + row, columns, + selectedRowId, + selectable, + onRowClick, + onRowHover, + onRowContextMenu, hasCheckbox, -}: { +}: DataRowProps) { + const isSelected = selectable?.selectedIds.has(row.id) ?? false + + const handleClick = useCallback(() => { + onRowClick?.(row.id) + }, [onRowClick, row.id]) + + const handleMouseEnter = useCallback(() => { + onRowHover?.(row.id) + }, [onRowHover, row.id]) + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + onRowContextMenu?.(e, row.id) + }, + [onRowContextMenu, row.id] + ) + + const handleSelectRow = useCallback( + (checked: boolean | 'indeterminate') => { + selectable?.onSelectRow(row.id, checked as boolean) + }, + [selectable, row.id] + ) + + return ( + + {hasCheckbox && selectable && ( + + + + )} + {columns.map((col, colIdx) => { + const cell = row.cells[col.id] + return ( + + + + ) + })} + + ) +}) + +interface CreateRowProps { + create: CreateAction + totalColSpan: number +} + +const CreateRow = memo(function CreateRow({ create, totalColSpan }: CreateRowProps) { + return ( + + + + {CREATE_ROW_PLUS_ICON} + {create.label} + + + + ) +}) + +interface ResourceColGroupProps { columns: ResourceColumn[] hasCheckbox?: boolean -}) { +} + +const ResourceColGroup = memo(function ResourceColGroup({ + columns, + hasCheckbox, +}: ResourceColGroupProps) { return ( {hasCheckbox && } @@ -486,17 +569,19 @@ function ResourceColGroup({ ))} ) +}) + +interface DataTableSkeletonProps { + columns: ResourceColumn[] + rowCount: number + hasCheckbox?: boolean } -function DataTableSkeleton({ +const DataTableSkeleton = memo(function DataTableSkeleton({ columns, rowCount, hasCheckbox, -}: { - columns: ResourceColumn[] - rowCount: number - hasCheckbox?: boolean -}) { +}: DataTableSkeletonProps) { return ( <>
@@ -549,4 +634,4 @@ function DataTableSkeleton({
) -} +}) 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 5075ee61baa..10be5a87703 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,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useRef, useState } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { Skeleton } from '@/components/emcn' import { cn } from '@/lib/core/utils/cn' @@ -183,6 +183,8 @@ function TextEditor({ } = useWorkspaceFileContent(workspaceId, file.id, file.key, file.type === 'text/x-pptxgenjs') const updateContent = useUpdateWorkspaceFileContent() + const updateContentRef = useRef(updateContent) + updateContentRef.current = updateContent const [content, setContent] = useState('') const [savedContent, setSavedContent] = useState('') @@ -230,14 +232,14 @@ function TextEditor({ const currentContent = contentRef.current if (currentContent === savedContentRef.current) return - await updateContent.mutateAsync({ + await updateContentRef.current.mutateAsync({ workspaceId, fileId: file.id, content: currentContent, }) setSavedContent(currentContent) savedContentRef.current = currentContent - }, [workspaceId, file.id, updateContent]) + }, [workspaceId, file.id]) const { saveStatus, saveImmediately, isDirty } = useAutosave({ content, @@ -402,7 +404,7 @@ function TextEditor({ ) } -function IframePreview({ file }: { file: WorkspaceFileRecord }) { +const IframePreview = memo(function IframePreview({ file }: { file: WorkspaceFileRecord }) { const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace` return ( @@ -417,9 +419,9 @@ function IframePreview({ file }: { file: WorkspaceFileRecord }) { /> ) -} +}) -function ImagePreview({ file }: { file: WorkspaceFileRecord }) { +const ImagePreview = memo(function ImagePreview({ file }: { file: WorkspaceFileRecord }) { const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace` return ( @@ -432,7 +434,7 @@ function ImagePreview({ file }: { file: WorkspaceFileRecord }) { /> ) -} +}) const pptxSlideCache = new Map() @@ -701,7 +703,11 @@ function PptxPreview({ ) } -function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) { +const UnsupportedPreview = memo(function UnsupportedPreview({ + file, +}: { + file: WorkspaceFileRecord +}) { const ext = getFileExtension(file.name) return ( @@ -714,4 +720,4 @@ function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {

) -} +}) 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 692cb510ac9..b3c9666d768 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 @@ -42,7 +42,12 @@ interface PreviewPanelProps { isStreaming?: boolean } -export function PreviewPanel({ content, mimeType, filename, isStreaming }: PreviewPanelProps) { +export const PreviewPanel = memo(function PreviewPanel({ + content, + mimeType, + filename, + isStreaming, +}: PreviewPanelProps) { const previewType = resolvePreviewType(mimeType, filename) if (previewType === 'markdown') @@ -52,7 +57,7 @@ export function PreviewPanel({ content, mimeType, filename, isStreaming }: Previ if (previewType === 'svg') return return null -} +}) const REMARK_PLUGINS = [remarkGfm, remarkBreaks] @@ -197,7 +202,7 @@ const MarkdownPreview = memo(function MarkdownPreview({ ) }) -function HtmlPreview({ content }: { content: string }) { +const HtmlPreview = memo(function HtmlPreview({ content }: { content: string }) { return (