From 42b99998783e4c875da050b2f0ed0e1c3ac6f85b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 18:25:06 -0700 Subject: [PATCH 01/36] feat(tables): add column selection, missing keyboard shortcuts, and Sheets-aligned operations Click column headers to select entire columns, shift-click to extend to a column range. Delete, cut, and copy operations work on column selections with full undo/redo support. Adds Home, End, Ctrl+Home, Ctrl+End, PageUp, PageDown, Ctrl+Space, and all Shift variants. Changes Ctrl+A to select all cells instead of checkbox rows. Column header dropdown menu now opens on right-click instead of left-click. Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 203 ++++++++++++++++-- 1 file changed, 181 insertions(+), 22 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index c24ad9e6b87..ccec7fab839 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -199,7 +199,9 @@ export function Table({ const [selectionAnchor, setSelectionAnchor] = useState(null) const [selectionFocus, setSelectionFocus] = useState(null) const [checkedRows, setCheckedRows] = useState(EMPTY_CHECKED_ROWS) + const [isColumnSelection, setIsColumnSelection] = useState(false) const lastCheckboxRowRef = useRef(null) + const isColumnSelectionRef = useRef(false) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) const [deletingColumn, setDeletingColumn] = useState(null) const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) @@ -362,6 +364,7 @@ export function Table({ rowsRef.current = rows selectionAnchorRef.current = selectionAnchor selectionFocusRef.current = selectionFocus + isColumnSelectionRef.current = isColumnSelection const deleteTableMutation = useDeleteTable(workspaceId) const renameTableMutation = useRenameTable(workspaceId) @@ -578,6 +581,7 @@ export function Table({ const handleCellMouseDown = useCallback( (rowIndex: number, colIndex: number, shiftKey: boolean) => { setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null if (shiftKey && selectionAnchorRef.current) { setSelectionFocus({ rowIndex, colIndex }) @@ -600,6 +604,7 @@ export function Table({ setEditingCell(null) setSelectionAnchor(null) setSelectionFocus(null) + setIsColumnSelection(false) if (shiftKey && lastCheckboxRowRef.current !== null) { const from = Math.min(lastCheckboxRowRef.current, rowIndex) @@ -631,9 +636,29 @@ export function Table({ setSelectionAnchor(null) setSelectionFocus(null) setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null }, []) + const handleColumnSelect = useCallback((colIndex: number, shiftKey: boolean) => { + const lastRow = maxPositionRef.current + if (lastRow < 0) return + + setEditingCell(null) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + lastCheckboxRowRef.current = null + + if (shiftKey && isColumnSelectionRef.current && selectionAnchorRef.current) { + setSelectionFocus({ rowIndex: lastRow, colIndex }) + } else { + setSelectionAnchor({ rowIndex: 0, colIndex }) + setSelectionFocus({ rowIndex: lastRow, colIndex }) + setIsColumnSelection(true) + } + + scrollRef.current?.focus({ preventScroll: true }) + }, []) + const handleSelectAllRows = useCallback(() => { const rws = rowsRef.current if (rws.length === 0) return @@ -723,6 +748,16 @@ export function Table({ } }, [tableData?.metadata]) + useEffect(() => { + if (!isColumnSelection || !selectionAnchor) return + setSelectionFocus((prev) => { + if (!prev || prev.rowIndex !== maxPosition) { + return { rowIndex: maxPosition, colIndex: prev?.colIndex ?? selectionAnchor.colIndex } + } + return prev + }) + }, [isColumnSelection, maxPosition, selectionAnchor]) + useEffect(() => { const handleMouseUp = () => { isDraggingRef.current = false @@ -814,6 +849,7 @@ export function Table({ setSelectionAnchor(null) setSelectionFocus(null) setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null return } @@ -821,19 +857,33 @@ export function Table({ if ((e.metaKey || e.ctrlKey) && e.key === 'a') { e.preventDefault() const rws = rowsRef.current - if (rws.length > 0) { + const currentCols = columnsRef.current + if (rws.length > 0 && currentCols.length > 0) { setEditingCell(null) - setSelectionAnchor(null) - setSelectionFocus(null) - const all = new Set() - for (const row of rws) { - all.add(row.position) - } - setCheckedRows(all) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) + setSelectionFocus({ + rowIndex: maxPositionRef.current, + colIndex: currentCols.length - 1, + }) + setIsColumnSelection(false) } return } + if ((e.metaKey || e.ctrlKey) && e.key === ' ') { + const a = selectionAnchorRef.current + if (!a || editingCellRef.current) return + const lastRow = maxPositionRef.current + if (lastRow < 0) return + e.preventDefault() + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setSelectionAnchor({ rowIndex: 0, colIndex: a.colIndex }) + setSelectionFocus({ rowIndex: lastRow, colIndex: a.colIndex }) + setIsColumnSelection(true) + return + } + if (e.key === ' ' && e.shiftKey) { const a = selectionAnchorRef.current if (!a || editingCellRef.current) return @@ -939,6 +989,7 @@ export function Table({ if (e.key === 'Tab') { e.preventDefault() setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null setSelectionAnchor(moveCell(anchor, cols.length, totalRows, e.shiftKey ? -1 : 1)) setSelectionFocus(null) @@ -948,6 +999,7 @@ export function Table({ if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) { e.preventDefault() setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) lastCheckboxRowRef.current = null const focus = selectionFocusRef.current ?? anchor const origin = e.shiftKey ? focus : anchor @@ -979,6 +1031,59 @@ export function Table({ return } + if (e.key === 'Home') { + e.preventDefault() + setIsColumnSelection(false) + const jump = e.metaKey || e.ctrlKey + if (e.shiftKey) { + setSelectionFocus({ rowIndex: jump ? 0 : anchor.rowIndex, colIndex: 0 }) + } else { + setSelectionAnchor({ rowIndex: jump ? 0 : anchor.rowIndex, colIndex: 0 }) + setSelectionFocus(null) + } + return + } + + if (e.key === 'End') { + e.preventDefault() + setIsColumnSelection(false) + const jump = e.metaKey || e.ctrlKey + if (e.shiftKey) { + setSelectionFocus({ + rowIndex: jump ? totalRows - 1 : anchor.rowIndex, + colIndex: cols.length - 1, + }) + } else { + setSelectionAnchor({ + rowIndex: jump ? totalRows - 1 : anchor.rowIndex, + colIndex: cols.length - 1, + }) + setSelectionFocus(null) + } + return + } + + if (e.key === 'PageUp' || e.key === 'PageDown') { + e.preventDefault() + setIsColumnSelection(false) + const scrollEl = scrollRef.current + const viewportHeight = scrollEl ? scrollEl.clientHeight : ROW_HEIGHT_ESTIMATE * 10 + const rowsPerPage = Math.max(1, Math.floor(viewportHeight / ROW_HEIGHT_ESTIMATE)) + const direction = e.key === 'PageUp' ? -1 : 1 + const origin = e.shiftKey ? (selectionFocusRef.current ?? anchor) : anchor + const newRow = Math.max( + 0, + Math.min(totalRows - 1, origin.rowIndex + direction * rowsPerPage) + ) + if (e.shiftKey) { + setSelectionFocus({ rowIndex: newRow, colIndex: origin.colIndex }) + } else { + setSelectionAnchor({ rowIndex: newRow, colIndex: anchor.colIndex }) + setSelectionFocus(null) + } + return + } + if (e.key === 'Delete' || e.key === 'Backspace') { if (!canEditRef.current) return e.preventDefault() @@ -1714,12 +1819,19 @@ export function Table({ checked={isAllRowsSelected} onCheckedChange={handleSelectAllToggle} /> - {displayColumns.map((column) => ( + {displayColumns.map((column, idx) => ( = normalizedSelection.startCol && + idx <= normalizedSelection.endCol + } renameValue={ columnRename.editingId === column.name ? columnRename.editValue : '' } @@ -1727,6 +1839,7 @@ export function Table({ onRenameSubmit={columnRename.submitRename} onRenameCancel={columnRename.cancelRename} onRenameColumn={handleRenameColumn} + onColumnSelect={handleColumnSelect} onChangeType={handleChangeType} onInsertLeft={handleInsertColumnLeft} onInsertRight={handleInsertColumnRight} @@ -2635,13 +2748,16 @@ const COLUMN_TYPE_OPTIONS: { type: string; label: string; icon: React.ElementTyp const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ column, + colIndex, readOnly, isRenaming, + isColumnSelected, renameValue, onRenameValueChange, onRenameSubmit, onRenameCancel, onRenameColumn, + onColumnSelect, onChangeType, onInsertLeft, onInsertRight, @@ -2657,13 +2773,16 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onDragLeave, }: { column: ColumnDefinition + colIndex: number readOnly?: boolean isRenaming: boolean + isColumnSelected: boolean renameValue: string onRenameValueChange: (value: string) => void onRenameSubmit: () => void onRenameCancel: () => void onRenameColumn: (columnName: string) => void + onColumnSelect: (colIndex: number, shiftKey: boolean) => void onChangeType: (columnName: string, newType: string) => void onInsertLeft: (columnName: string) => void onInsertRight: (columnName: string) => void @@ -2679,6 +2798,8 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onDragLeave?: () => void }) { const renameInputRef = useRef(null) + const [menuOpen, setMenuOpen] = useState(false) + const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 }) useEffect(() => { if (isRenaming && renameInputRef.current) { @@ -2761,15 +2882,35 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ [onDragLeave] ) + const handleHeaderClick = useCallback( + (e: React.MouseEvent) => { + if (isRenaming) return + onColumnSelect(colIndex, e.shiftKey) + }, + [colIndex, isRenaming, onColumnSelect] + ) + + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + if (readOnly || isRenaming) return + e.preventDefault() + setMenuPosition({ x: e.clientX, y: e.clientY }) + setMenuOpen(true) + }, + [readOnly, isRenaming] + ) + return ( {isRenaming ? (
@@ -2796,20 +2937,38 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
) : (
- + + - +
- + e.preventDefault()} + > onRenameColumn(column.name)}> Rename column From 67e4d03c9c8860e87d1440a570b4120e6cb5e0ac Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 18:33:17 -0700 Subject: [PATCH 02/36] fix(tables): chevron opens dropdown, drag header to reorder columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split column header into label area (click to select, draggable for reorder) and chevron button (click to open dropdown menu). Remove the grip handle — dragging the header itself now reorders columns. Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index ccec7fab839..dd6ff3f3cc4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1,7 +1,6 @@ 'use client' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { GripVertical } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { @@ -2890,6 +2889,15 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ [colIndex, isRenaming, onColumnSelect] ) + const handleChevronClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + const rect = (e.currentTarget as HTMLElement).closest('th')?.getBoundingClientRect() + if (rect) { + setMenuPosition({ x: rect.left, y: rect.bottom }) + } + setMenuOpen(true) + }, []) + const handleContextMenu = useCallback( (e: React.MouseEvent) => { if (readOnly || isRenaming) return @@ -2907,6 +2915,9 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ isDragging && 'opacity-40', isColumnSelected && 'bg-[rgba(37,99,235,0.06)]' )} + draggable={!readOnly && !isRenaming} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} onDragOver={handleDragOver} onDrop={handleDrop} onDragLeave={handleDragLeave} @@ -2941,12 +2952,20 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ type='button' className='flex min-w-0 flex-1 cursor-pointer items-center px-2 py-[7px] outline-none' onClick={handleHeaderClick} + draggable={false} > {column.name} - + + @@ -3012,14 +3031,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ -
- -
)}
Date: Fri, 17 Apr 2026 18:36:48 -0700 Subject: [PATCH 03/36] fix(tables): full-column highlight during drag reorder Replace the thin 2px line drop indicator with a full-column highlight that spans the entire table height, matching Google Sheets behavior. The insertion line is still shown at the drop edge for precision. Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index dd6ff3f3cc4..831db9375fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -318,16 +318,19 @@ export function Table({ return 0 }, [resizingColumn, displayColumns, columnWidths]) - const dropIndicatorLeft = useMemo(() => { - if (!dropTargetColumnName) return null + const dropColumnBounds = useMemo(() => { + if (!dropTargetColumnName || !dragColumnName) return null let left = CHECKBOX_COL_WIDTH for (const col of displayColumns) { - if (dropSide === 'left' && col.name === dropTargetColumnName) return left - left += columnWidths[col.name] ?? COL_WIDTH - if (dropSide === 'right' && col.name === dropTargetColumnName) return left + const w = columnWidths[col.name] ?? COL_WIDTH + if (col.name === dropTargetColumnName) { + const lineLeft = dropSide === 'left' ? left : left + w + return { left, width: w, lineLeft } + } + left += w } return null - }, [dropTargetColumnName, dropSide, displayColumns, columnWidths]) + }, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths]) const isAllRowsSelected = useMemo(() => { if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { @@ -1924,11 +1927,17 @@ export function Table({ style={{ left: resizeIndicatorLeft }} /> )} - {dropIndicatorLeft !== null && ( -
+ {dropColumnBounds !== null && ( + <> +
+
+ )}
{!isLoadingTable && !isLoadingRows && userPermissions.canEdit && ( From 36418621cb2d7e1bf5f90321b23d5202274c74d3 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 18:38:52 -0700 Subject: [PATCH 04/36] fix(tables): handle drag reorder edge cases, dim source column Suppress drop indicator when drag would result in no position change (dragging onto self or adjacent no-op positions). Dim the source column body cells during drag with a background overlay. Skip the API call when the computed order is identical to the current order. Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 48 ++++++++++++++++--- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 831db9375fa..b4601ad6a1a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -320,6 +320,17 @@ export function Table({ const dropColumnBounds = useMemo(() => { if (!dropTargetColumnName || !dragColumnName) return null + if (dropTargetColumnName === dragColumnName) return null + + const dragIndex = displayColumns.findIndex((c) => c.name === dragColumnName) + const targetIndex = displayColumns.findIndex((c) => c.name === dropTargetColumnName) + if (dragIndex === -1 || targetIndex === -1) return null + + const wouldBeNoOp = + (dropSide === 'right' && targetIndex === dragIndex - 1) || + (dropSide === 'left' && targetIndex === dragIndex + 1) + if (wouldBeNoOp) return null + let left = CHECKBOX_COL_WIDTH for (const col of displayColumns) { const w = columnWidths[col.name] ?? COL_WIDTH @@ -332,6 +343,17 @@ export function Table({ return null }, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths]) + const dragSourceBounds = useMemo(() => { + if (!dragColumnName) return null + let left = CHECKBOX_COL_WIDTH + for (const col of displayColumns) { + const w = columnWidths[col.name] ?? COL_WIDTH + if (col.name === dragColumnName) return { left, width: w } + left += w + } + return null + }, [dragColumnName, displayColumns, columnWidths]) + const isAllRowsSelected = useMemo(() => { if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { for (const row of rows) { @@ -708,7 +730,12 @@ export function Table({ const handleColumnDragEnd = useCallback(() => { const dragged = dragColumnNameRef.current - if (!dragged) return + if (!dragged) { + setDragColumnName(null) + setDropTargetColumnName(null) + setDropSide('left') + return + } const target = dropTargetColumnNameRef.current const side = dropSideRef.current if (target && dragged !== target) { @@ -721,11 +748,14 @@ export function Table({ let insertIndex = newOrder.indexOf(target) if (side === 'right') insertIndex += 1 newOrder.splice(insertIndex, 0, dragged) - setColumnOrder(newOrder) - updateMetadataRef.current({ - columnWidths: columnWidthsRef.current, - columnOrder: newOrder, - }) + const orderChanged = newOrder.some((name, i) => currentOrder[i] !== name) + if (orderChanged) { + setColumnOrder(newOrder) + updateMetadataRef.current({ + columnWidths: columnWidthsRef.current, + columnOrder: newOrder, + }) + } } } setDragColumnName(null) @@ -1927,6 +1957,12 @@ export function Table({ style={{ left: resizeIndicatorLeft }} /> )} + {dragSourceBounds !== null && ( +
+ )} {dropColumnBounds !== null && ( <>
Date: Fri, 17 Apr 2026 18:46:03 -0700 Subject: [PATCH 05/36] feat(tables): add column reorder undo/redo, body drop targets, and escape cancel Column drag-and-drop now supports dropping anywhere in a column (not just headers), pressing Escape to cancel a drag, and full undo/redo integration for column reordering. Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 54 ++++++++++++++++++- apps/sim/hooks/use-table-undo.ts | 17 +++++- apps/sim/stores/table/types.ts | 1 + 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index b4601ad6a1a..693c8f4a3d4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -251,7 +251,15 @@ export function Table({ const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId }) const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) - const { pushUndo, undo, redo } = useTableUndo({ workspaceId, tableId }) + const handleColumnOrderChange = useCallback((order: string[]) => { + setColumnOrder(order) + }, []) + + const { pushUndo, undo, redo } = useTableUndo({ + workspaceId, + tableId, + onColumnOrderChange: handleColumnOrderChange, + }) const undoRef = useRef(undo) undoRef.current = undo const redoRef = useRef(redo) @@ -750,6 +758,11 @@ export function Table({ newOrder.splice(insertIndex, 0, dragged) const orderChanged = newOrder.some((name, i) => currentOrder[i] !== name) if (orderChanged) { + pushUndoRef.current({ + type: 'reorder-columns', + previousOrder: currentOrder, + newOrder, + }) setColumnOrder(newOrder) updateMetadataRef.current({ columnWidths: columnWidthsRef.current, @@ -768,6 +781,37 @@ export function Table({ setDropTargetColumnName(null) }, []) + const handleScrollDragOver = useCallback((e: React.DragEvent) => { + if (!dragColumnNameRef.current) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + + const scrollEl = scrollRef.current + if (!scrollEl) return + const scrollRect = scrollEl.getBoundingClientRect() + const cursorX = e.clientX - scrollRect.left + scrollEl.scrollLeft + + const cols = columnsRef.current + let left = CHECKBOX_COL_WIDTH + for (const col of cols) { + const w = columnWidthsRef.current[col.name] ?? COL_WIDTH + if (cursorX < left + w) { + const midX = left + w / 2 + const side = cursorX < midX ? 'left' : 'right' + if (col.name !== dropTargetColumnNameRef.current || side !== dropSideRef.current) { + setDropTargetColumnName(col.name) + setDropSide(side) + } + return + } + left += w + } + }, []) + + const handleScrollDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + useEffect(() => { if (!tableData?.metadata || metadataSeededRef.current) return if (!tableData.metadata.columnWidths && !tableData.metadata.columnOrder) return @@ -878,6 +922,12 @@ export function Table({ if (e.key === 'Escape') { e.preventDefault() + if (dragColumnNameRef.current) { + setDragColumnName(null) + setDropTargetColumnName(null) + setDropSide('left') + return + } setSelectionAnchor(null) setSelectionFocus(null) setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) @@ -1805,6 +1855,8 @@ export function Table({ resizingColumn && 'select-none' )} data-table-scroll + onDragOver={handleScrollDragOver} + onDrop={handleScrollDrop} >
): string | interface UseTableUndoProps { workspaceId: string tableId: string + onColumnOrderChange?: (order: string[]) => void } -export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { +export function useTableUndo({ workspaceId, tableId, onColumnOrderChange }: UseTableUndoProps) { const push = useTableUndoStore((s) => s.push) const popUndo = useTableUndoStore((s) => s.popUndo) const popRedo = useTableUndoStore((s) => s.popRedo) @@ -55,6 +57,10 @@ export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { const updateColumnMutation = useUpdateColumn({ workspaceId, tableId }) const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId }) const renameTableMutation = useRenameTable(workspaceId) + const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) + + const onColumnOrderChangeRef = useRef(onColumnOrderChange) + onColumnOrderChangeRef.current = onColumnOrderChange useEffect(() => { return () => clear(tableId) @@ -229,6 +235,13 @@ export function useTableUndo({ workspaceId, tableId }: UseTableUndoProps) { renameTableMutation.mutate({ tableId: action.tableId, name }) break } + + case 'reorder-columns': { + const order = direction === 'undo' ? action.previousOrder : action.newOrder + onColumnOrderChangeRef.current?.(order) + updateMetadataMutation.mutate({ columnOrder: order }) + break + } } } catch (err) { logger.error('Failed to execute undo/redo action', { action, direction, err }) diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index fbea638f014..06d6f093202 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -42,6 +42,7 @@ export type TableUndoAction = newValue: boolean } | { type: 'rename-table'; tableId: string; previousName: string; newName: string } + | { type: 'reorder-columns'; previousOrder: string[]; newOrder: string[] } export interface UndoEntry { id: string From 31df0e9effda49705884d7b8a0485d633a6cfb8f Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 17 Apr 2026 18:50:45 -0700 Subject: [PATCH 06/36] fix(tables): merge partial updates in updateRow to prevent column data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Mothership called updateRow directly (bypassing the PATCH API route), it passed only the changed fields — which were written as the entire row, wiping all other columns. Move the merge logic into updateRow itself so all callers get correct partial-update semantics, and remove the now-redundant pre-merge from both PATCH routes. --- .../api/table/[tableId]/rows/[rowId]/route.ts | 23 +----------------- .../v1/tables/[tableId]/rows/[rowId]/route.ts | 24 +------------------ apps/sim/lib/table/service.ts | 16 +++++++++---- 3 files changed, 13 insertions(+), 50 deletions(-) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts index 63da0c91de0..66790f68af7 100644 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/table/[tableId]/rows/[rowId]/route.ts @@ -135,32 +135,11 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - const [existingRow] = await db - .select({ data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId) - ) - ) - .limit(1) - - if (!existingRow) { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - - const mergedData = { - ...(existingRow.data as RowData), - ...(validated.data as RowData), - } - const updatedRow = await updateRow( { tableId, rowId, - data: mergedData, + data: validated.data as RowData, workspaceId: validated.workspaceId, }, table, diff --git a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts index c8712e44de7..af0d8525cc2 100644 --- a/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts +++ b/apps/sim/app/api/v1/tables/[tableId]/rows/[rowId]/route.ts @@ -137,33 +137,11 @@ export async function PATCH(request: NextRequest, { params }: RowRouteParams) { return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) } - // Fetch existing row to merge partial update - const [existingRow] = await db - .select({ data: userTableRows.data }) - .from(userTableRows) - .where( - and( - eq(userTableRows.id, rowId), - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, validated.workspaceId) - ) - ) - .limit(1) - - if (!existingRow) { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - - const mergedData = { - ...(existingRow.data as RowData), - ...(validated.data as RowData), - } - const updatedRow = await updateRow( { tableId, rowId, - data: mergedData, + data: validated.data as RowData, workspaceId: validated.workspaceId, }, table, diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index cfa1f5dbecb..a8e0bdecb71 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -1207,14 +1207,20 @@ export async function updateRow( throw new Error('Row not found') } + // Merge partial update with existing row data so callers can pass only changed fields + const mergedData = { + ...(existingRow.data as RowData), + ...data.data, + } + // Validate size - const sizeValidation = validateRowSize(data.data) + const sizeValidation = validateRowSize(mergedData) if (!sizeValidation.valid) { throw new Error(sizeValidation.errors.join(', ')) } // Validate against schema - const schemaValidation = validateRowAgainstSchema(data.data, table.schema) + const schemaValidation = validateRowAgainstSchema(mergedData, table.schema) if (!schemaValidation.valid) { throw new Error(`Schema validation failed: ${schemaValidation.errors.join(', ')}`) } @@ -1224,7 +1230,7 @@ export async function updateRow( if (uniqueColumns.length > 0) { const uniqueValidation = await checkUniqueConstraintsDb( data.tableId, - data.data, + mergedData, table.schema, data.rowId // Exclude current row ) @@ -1237,14 +1243,14 @@ export async function updateRow( await db .update(userTableRows) - .set({ data: data.data, updatedAt: now }) + .set({ data: mergedData, updatedAt: now }) .where(eq(userTableRows.id, data.rowId)) logger.info(`[${requestId}] Updated row ${data.rowId} in table ${data.tableId}`) return { id: data.rowId, - data: data.data, + data: mergedData, position: existingRow.position, createdAt: existingRow.createdAt, updatedAt: now, From 459c9295debfca76e3140bb5865b835ddb7e7cb8 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 17 Apr 2026 18:54:17 -0700 Subject: [PATCH 07/36] test(tables): add updateRow partial merge tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers the bug where partial updates wiped unmentioned columns — verifies that fields not in the update payload are preserved, nulling a field works, full-row updates are idempotent, and missing rows throw correctly. --- .../lib/table/__tests__/update-row.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 apps/sim/lib/table/__tests__/update-row.test.ts diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts new file mode 100644 index 00000000000..ef899da0f9f --- /dev/null +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -0,0 +1,120 @@ +/** + * @vitest-environment node + */ +import { databaseMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { updateRow } from '../service' +import type { TableDefinition } from '../types' + +const EXISTING_ROW = { + id: 'row-1', + tableId: 'tbl-1', + workspaceId: 'ws-1', + data: { name: 'Alice', age: 30 }, + position: 1, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), +} + +const TABLE: TableDefinition = { + id: 'tbl-1', + name: 'People', + description: null, + schema: { + columns: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }, + metadata: null, + rowCount: 0, + maxRows: 1000, + workspaceId: 'ws-1', + createdBy: 'user-1', + archivedAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), +} + +describe('updateRow — partial merge', () => { + let mockSet: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + + // db.select() → used by getRowById to fetch the existing row + const mockLimit = vi.fn().mockResolvedValue([EXISTING_ROW]) + const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockFrom = vi.fn().mockReturnValue({ where: mockSelectWhere }) + databaseMock.db.select.mockReturnValue({ from: mockFrom }) + + // db.update() → captures what merged data gets written + const mockUpdateWhere = vi.fn().mockResolvedValue([]) + mockSet = vi.fn().mockReturnValue({ where: mockUpdateWhere }) + databaseMock.db.update.mockReturnValue({ set: mockSet }) + }) + + it('preserves columns not included in the partial update', async () => { + const result = await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { age: 31 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(result.data).toEqual({ name: 'Alice', age: 31 }) + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ data: { name: 'Alice', age: 31 } }) + ) + }) + + it('allows updating a single column without affecting others', async () => { + const result = await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob' }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(result.data).toEqual({ name: 'Bob', age: 30 }) + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ data: { name: 'Bob', age: 30 } }) + ) + }) + + it('allows explicitly nulling a field while preserving others', async () => { + const result = await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { age: null }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(result.data).toEqual({ name: 'Alice', age: null }) + expect(mockSet).toHaveBeenCalledWith( + expect.objectContaining({ data: { name: 'Alice', age: null } }) + ) + }) + + it('handles a full-row update correctly (idempotent merge)', async () => { + const result = await updateRow( + { tableId: 'tbl-1', rowId: 'row-1', data: { name: 'Bob', age: 25 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + + expect(result.data).toEqual({ name: 'Bob', age: 25 }) + }) + + it('throws when the row does not exist', async () => { + const mockLimit = vi.fn().mockResolvedValue([]) + const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockLimit }) + const mockFrom = vi.fn().mockReturnValue({ where: mockSelectWhere }) + databaseMock.db.select.mockReturnValue({ from: mockFrom }) + + await expect( + updateRow( + { tableId: 'tbl-1', rowId: 'row-missing', data: { age: 31 }, workspaceId: 'ws-1' }, + TABLE, + 'req-1' + ) + ).rejects.toThrow('Row not found') + }) +}) From 2ea5d142b0fcf23f816420e488a4db698192f8ba Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 18:54:59 -0700 Subject: [PATCH 08/36] feat(tables): add delete-column undo/redo, rename metadata sync, and comprehensive row ID patching - Delete column now captures column definition, cell data, order, and width for full undo/redo - Column rename undo/redo now properly syncs columnWidths and columnOrder metadata - patchRedoRowId/patchUndoRowId extended to handle all action types containing row IDs Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 36 ++++++++ apps/sim/hooks/use-table-undo.ts | 71 +++++++++++++-- apps/sim/stores/table/store.ts | 86 +++++++++++++++---- apps/sim/stores/table/types.ts | 10 +++ 4 files changed, 177 insertions(+), 26 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 693c8f4a3d4..96e0a8e93a9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -255,10 +255,26 @@ export function Table({ setColumnOrder(order) }, []) + const handleColumnRename = useCallback((oldName: string, newName: string) => { + let updatedWidths = columnWidthsRef.current + if (oldName in updatedWidths) { + const { [oldName]: width, ...rest } = updatedWidths + updatedWidths = { ...rest, [newName]: width } + setColumnWidths(updatedWidths) + } + const updatedOrder = columnOrderRef.current?.map((n) => (n === oldName ? newName : n)) + if (updatedOrder) setColumnOrder(updatedOrder) + updateMetadataRef.current({ + columnWidths: updatedWidths, + columnOrder: updatedOrder, + }) + }, []) + const { pushUndo, undo, redo } = useTableUndo({ workspaceId, tableId, onColumnOrderChange: handleColumnOrderChange, + onColumnRename: handleColumnRename, }) const undoRef = useRef(undo) undoRef.current = undo @@ -1631,6 +1647,26 @@ export function Table({ if (!deletingColumn) return const columnToDelete = deletingColumn const orderAtDelete = columnOrderRef.current + const cols = schemaColumnsRef.current + const colDef = cols.find((c) => c.name === columnToDelete) + const colPosition = cols.indexOf(colDef!) + const currentRows = rowsRef.current + const cellData = currentRows + .filter((r) => r.data[columnToDelete] != null) + .map((r) => ({ rowId: r.id, value: r.data[columnToDelete] })) + const previousWidth = columnWidthsRef.current[columnToDelete] ?? null + + pushUndoRef.current({ + type: 'delete-column', + columnName: columnToDelete, + columnType: colDef?.type ?? 'string', + columnPosition: colPosition >= 0 ? colPosition : cols.length, + columnUnique: colDef?.unique ?? false, + cellData, + previousOrder: orderAtDelete ? [...orderAtDelete] : null, + previousWidth, + }) + setDeletingColumn(null) deleteColumnMutation.mutate(columnToDelete, { onSuccess: () => { diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 1a83e011d51..da6af4042a2 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -35,9 +35,15 @@ interface UseTableUndoProps { workspaceId: string tableId: string onColumnOrderChange?: (order: string[]) => void + onColumnRename?: (oldName: string, newName: string) => void } -export function useTableUndo({ workspaceId, tableId, onColumnOrderChange }: UseTableUndoProps) { +export function useTableUndo({ + workspaceId, + tableId, + onColumnOrderChange, + onColumnRename, +}: UseTableUndoProps) { const push = useTableUndoStore((s) => s.push) const popUndo = useTableUndoStore((s) => s.popUndo) const popRedo = useTableUndoStore((s) => s.popRedo) @@ -61,6 +67,8 @@ export function useTableUndo({ workspaceId, tableId, onColumnOrderChange }: UseT const onColumnOrderChangeRef = useRef(onColumnOrderChange) onColumnOrderChangeRef.current = onColumnOrderChange + const onColumnRenameRef = useRef(onColumnRename) + onColumnRenameRef.current = onColumnRename useEffect(() => { return () => clear(tableId) @@ -197,21 +205,66 @@ export function useTableUndo({ workspaceId, tableId, onColumnOrderChange }: UseT break } - case 'rename-column': { + case 'delete-column': { if (direction === 'undo') { - updateColumnMutation.mutate({ - columnName: action.newName, - updates: { name: action.oldName }, - }) + addColumnMutation.mutate( + { + name: action.columnName, + type: action.columnType, + unique: action.columnUnique, + position: action.columnPosition, + }, + { + onSuccess: () => { + if (action.cellData.length > 0) { + const updates = action.cellData.map((c) => ({ + rowId: c.rowId, + data: { [action.columnName]: c.value }, + })) + batchUpdateRowsMutation.mutate({ updates }) + } + if (action.previousOrder) { + onColumnOrderChangeRef.current?.(action.previousOrder) + updateMetadataMutation.mutate({ + columnOrder: action.previousOrder, + ...(action.previousWidth !== null + ? { + columnWidths: { + ...({} as Record), + [action.columnName]: action.previousWidth, + }, + } + : {}), + }) + } + }, + } + ) } else { - updateColumnMutation.mutate({ - columnName: action.oldName, - updates: { name: action.newName }, + deleteColumnMutation.mutate(action.columnName, { + onSuccess: () => { + if (action.previousOrder) { + const newOrder = action.previousOrder.filter((n) => n !== action.columnName) + onColumnOrderChangeRef.current?.(newOrder) + updateMetadataMutation.mutate({ columnOrder: newOrder }) + } + }, }) } break } + case 'rename-column': { + const fromName = direction === 'undo' ? action.newName : action.oldName + const toName = direction === 'undo' ? action.oldName : action.newName + updateColumnMutation.mutate({ + columnName: fromName, + updates: { name: toName }, + }) + onColumnRenameRef.current?.(fromName, toName) + break + } + case 'update-column-type': { const type = direction === 'undo' ? action.previousType : action.newType updateColumnMutation.mutate({ diff --git a/apps/sim/stores/table/store.ts b/apps/sim/stores/table/store.ts index 3b5b1d3de96..162b343ff1a 100644 --- a/apps/sim/stores/table/store.ts +++ b/apps/sim/stores/table/store.ts @@ -13,6 +13,73 @@ const EMPTY_STACKS: TableUndoStacks = { undo: [], redo: [] } let undoRedoInProgress = false +function patchRowIdInEntry(entry: UndoEntry, oldRowId: string, newRowId: string): UndoEntry { + const { action } = entry + switch (action.type) { + case 'update-cell': + if (action.rowId === oldRowId) { + return { ...entry, action: { ...action, rowId: newRowId } } + } + break + case 'clear-cells': { + const hasMatch = action.cells.some((c) => c.rowId === oldRowId) + if (hasMatch) { + const patched = action.cells.map((c) => + c.rowId === oldRowId ? { ...c, rowId: newRowId } : c + ) + return { ...entry, action: { ...action, cells: patched } } + } + break + } + case 'update-cells': { + const hasMatch = action.cells.some((c) => c.rowId === oldRowId) + if (hasMatch) { + const patched = action.cells.map((c) => + c.rowId === oldRowId ? { ...c, rowId: newRowId } : c + ) + return { ...entry, action: { ...action, cells: patched } } + } + break + } + case 'create-row': + if (action.rowId === oldRowId) { + return { ...entry, action: { ...action, rowId: newRowId } } + } + break + case 'create-rows': { + const hasMatch = action.rows.some((r) => r.rowId === oldRowId) + if (hasMatch) { + const patched = action.rows.map((r) => + r.rowId === oldRowId ? { ...r, rowId: newRowId } : r + ) + return { ...entry, action: { ...action, rows: patched } } + } + break + } + case 'delete-rows': { + const hasMatch = action.rows.some((r) => r.rowId === oldRowId) + if (hasMatch) { + const patched = action.rows.map((r) => + r.rowId === oldRowId ? { ...r, rowId: newRowId } : r + ) + return { ...entry, action: { ...action, rows: patched } } + } + break + } + case 'delete-column': { + const hasMatch = action.cellData.some((c) => c.rowId === oldRowId) + if (hasMatch) { + const patched = action.cellData.map((c) => + c.rowId === oldRowId ? { ...c, rowId: newRowId } : c + ) + return { ...entry, action: { ...action, cellData: patched } } + } + break + } + } + return entry +} + /** * Run a function without recording undo entries. * Used by the hook when executing undo/redo mutations to prevent recursive recording. @@ -86,16 +153,7 @@ export const useTableUndoStore = create()( const stacks = get().stacks[tableId] if (!stacks) return - const patchedRedo = stacks.redo.map((entry) => { - const { action } = entry - if (action.type === 'delete-rows') { - const patchedRows = action.rows.map((r) => - r.rowId === oldRowId ? { ...r, rowId: newRowId } : r - ) - return { ...entry, action: { ...action, rows: patchedRows } } - } - return entry - }) + const patchedRedo = stacks.redo.map((entry) => patchRowIdInEntry(entry, oldRowId, newRowId)) set((state) => ({ stacks: { @@ -109,13 +167,7 @@ export const useTableUndoStore = create()( const stacks = get().stacks[tableId] if (!stacks) return - const patchedUndo = stacks.undo.map((entry) => { - const { action } = entry - if (action.type === 'create-row' && action.rowId === oldRowId) { - return { ...entry, action: { ...action, rowId: newRowId } } - } - return entry - }) + const patchedUndo = stacks.undo.map((entry) => patchRowIdInEntry(entry, oldRowId, newRowId)) set((state) => ({ stacks: { diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index 06d6f093202..2bc4ea1fe5d 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -32,6 +32,16 @@ export type TableUndoAction = } | { type: 'delete-rows'; rows: DeletedRowSnapshot[] } | { type: 'create-column'; columnName: string; position: number } + | { + type: 'delete-column' + columnName: string + columnType: string + columnPosition: number + columnUnique: boolean + cellData: Array<{ rowId: string; value: unknown }> + previousOrder: string[] | null + previousWidth: number | null + } | { type: 'rename-column'; oldName: string; newName: string } | { type: 'update-column-type'; columnName: string; previousType: string; newType: string } | { From abafacbd584b2890ac37aafdda9570649c9332ab Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 19:00:04 -0700 Subject: [PATCH 09/36] fix(tables): remove source column dimming during drag reorder Only show the insertion line at the drop position, matching Google Sheets behavior. Remove dragSourceBounds memo and isDragging prop. Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 96e0a8e93a9..c2523be2e81 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -367,17 +367,6 @@ export function Table({ return null }, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths]) - const dragSourceBounds = useMemo(() => { - if (!dragColumnName) return null - let left = CHECKBOX_COL_WIDTH - for (const col of displayColumns) { - const w = columnWidths[col.name] ?? COL_WIDTH - if (col.name === dragColumnName) return { left, width: w } - left += w - } - return null - }, [dragColumnName, displayColumns, columnWidths]) - const isAllRowsSelected = useMemo(() => { if (checkedRows.size > 0 && rows.length > 0 && checkedRows.size >= rows.length) { for (const row of rows) { @@ -1968,7 +1957,6 @@ export function Table({ onResizeStart={handleColumnResizeStart} onResize={handleColumnResize} onResizeEnd={handleColumnResizeEnd} - isDragging={dragColumnName === column.name} onDragStart={handleColumnDragStart} onDragOver={handleColumnDragOver} onDragEnd={handleColumnDragEnd} @@ -2045,12 +2033,6 @@ export function Table({ style={{ left: resizeIndicatorLeft }} /> )} - {dragSourceBounds !== null && ( -
- )} {dropColumnBounds !== null && ( <>
void onResize: (columnName: string, width: number) => void onResizeEnd: () => void - isDragging?: boolean onDragStart?: (columnName: string) => void onDragOver?: (columnName: string, side: 'left' | 'right') => void onDragEnd?: () => void @@ -3045,7 +3025,6 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
) From 8fac87550ae85ab626a9e767f03cec1d9fc727e0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 19:15:22 -0700 Subject: [PATCH 11/36] fix(tables): add aria-hidden value and aria-label for column header accessibility Co-Authored-By: Claude Opus 4.6 --- .../[workspaceId]/tables/[tableId]/components/table/table.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index b6cfadf47ab..d65c187d900 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -3130,6 +3130,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ className='flex h-full shrink-0 cursor-pointer items-center pr-2 pl-0.5 text-[var(--text-muted)] opacity-0 transition-opacity hover:text-[var(--text-primary)] group-hover:opacity-100' onClick={handleChevronClick} draggable={false} + aria-label='Column options' > @@ -3145,7 +3146,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ pointerEvents: 'none', }} tabIndex={-1} - aria-hidden + aria-hidden='true' /> Date: Fri, 17 Apr 2026 19:16:27 -0700 Subject: [PATCH 12/36] fix(tables): tighten auto-resize padding to match Google Sheets Reduce header padding from +48px to +36px (icon + cell padding) and cell padding from +20px to +17px (cell padding + border) for a snug fit. Co-Authored-By: Claude Opus 4.6 --- .../tables/[tableId]/components/table/table.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index d65c187d900..c8a3d11d07d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -754,20 +754,19 @@ export function Table({ let maxWidth = COL_WIDTH_MIN const measure = document.createElement('span') - measure.style.cssText = - 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit;padding:0 8px' + measure.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit' document.body.appendChild(measure) measure.className = 'font-medium text-small' measure.textContent = columnName - maxWidth = Math.max(maxWidth, measure.offsetWidth + 48) + maxWidth = Math.max(maxWidth, measure.offsetWidth + 36) measure.className = 'text-small' for (const row of currentRows) { const val = row.data[columnName] if (val == null) continue measure.textContent = String(val) - maxWidth = Math.max(maxWidth, measure.offsetWidth + 20) + maxWidth = Math.max(maxWidth, measure.offsetWidth + 17) } document.body.removeChild(measure) From d90d90ea093261cdf0742c7d3eba854d026ed315 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 19:20:42 -0700 Subject: [PATCH 13/36] fix(tables): clean drag ghost and clear selection on drag start - Create a minimal custom drag image showing only the column name instead of the browser's default ghost that includes adjacent columns/checkboxes - Clear any existing cell/column selection when starting a column drag to prevent stale highlights from persisting during reorder Co-Authored-By: Claude Opus 4.6 --- .../tables/[tableId]/components/table/table.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index c8a3d11d07d..24ed1d1f633 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -780,6 +780,10 @@ export function Table({ const handleColumnDragStart = useCallback((columnName: string) => { setDragColumnName(columnName) + setSelectionAnchor(null) + setSelectionFocus(null) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) }, []) const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { @@ -3012,6 +3016,15 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ } e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', column.name) + + const ghost = document.createElement('div') + ghost.textContent = column.name + ghost.style.cssText = + 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' + document.body.appendChild(ghost) + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + requestAnimationFrame(() => document.body.removeChild(ghost)) + onDragStart?.(column.name) }, [column.name, readOnly, isRenaming, onDragStart] From a7f94a4602b6be750db714956ea757450b1b3783 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 19:31:07 -0700 Subject: [PATCH 14/36] feat(tables): add Shift+Space row selection and Ctrl+D fill down Shift+Space now selects the entire row (all columns) instead of toggling a checkbox, matching Google Sheets behavior. Ctrl+D copies the top cell's value down through the selected range with full undo/redo support. Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 51 +++++++++++++++---- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 24ed1d1f633..71faa1aa5d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1028,18 +1028,13 @@ export function Table({ if (e.key === ' ' && e.shiftKey) { const a = selectionAnchorRef.current if (!a || editingCellRef.current) return + const currentCols = columnsRef.current + if (currentCols.length === 0) return e.preventDefault() - setSelectionFocus(null) - setCheckedRows((prev) => { - const next = new Set(prev) - if (next.has(a.rowIndex)) { - next.delete(a.rowIndex) - } else { - next.add(a.rowIndex) - } - return next - }) - lastCheckboxRowRef.current = a.rowIndex + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setIsColumnSelection(false) + setSelectionAnchor({ rowIndex: a.rowIndex, colIndex: 0 }) + setSelectionFocus({ rowIndex: a.rowIndex, colIndex: currentCols.length - 1 }) return } @@ -1225,6 +1220,40 @@ export function Table({ return } + if ((e.metaKey || e.ctrlKey) && e.key === 'd') { + if (!canEditRef.current) return + const sel = computeNormalizedSelection(anchor, selectionFocusRef.current) + if (!sel || sel.startRow === sel.endRow) return + e.preventDefault() + const pMap = positionMapRef.current + const sourceRow = pMap.get(sel.startRow) + if (!sourceRow) return + const undoCells: Array<{ + rowId: string + oldData: Record + newData: Record + }> = [] + for (let r = sel.startRow + 1; r <= sel.endRow; r++) { + const row = pMap.get(r) + if (!row) continue + const oldData: Record = {} + const newData: Record = {} + for (let c = sel.startCol; c <= sel.endCol; c++) { + if (c < cols.length) { + const colName = cols[c].name + oldData[colName] = row.data[colName] ?? null + newData[colName] = sourceRow.data[colName] ?? null + } + } + undoCells.push({ rowId: row.id, oldData, newData }) + mutateRef.current({ rowId: row.id, data: newData }) + } + if (undoCells.length > 0) { + pushUndoRef.current({ type: 'update-cells', cells: undoCells }) + } + return + } + if (e.key === 'Delete' || e.key === 'Backspace') { if (!canEditRef.current) return e.preventDefault() From f0b5ad6ac808c871d312449cbaf6a4fea1d756c1 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 19:34:03 -0700 Subject: [PATCH 15/36] fix(tables): show toast on incompatible column type change The server validates type compatibility and returns a clear error message (e.g. "3 row(s) have incompatible values"), but the client was silently swallowing it. Now surfaces the error via toast notification. Also moved the undo push to onSuccess so a failed type change doesn't pollute the undo stack. Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 25 ++++++++++++------- apps/sim/hooks/queries/tables.ts | 3 +++ 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 71faa1aa5d0..1f981601ecc 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1624,15 +1624,22 @@ export function Table({ const handleChangeType = useCallback((columnName: string, newType: string) => { const column = columnsRef.current.find((c) => c.name === columnName) - if (column) { - pushUndoRef.current({ - type: 'update-column-type', - columnName, - previousType: column.type, - newType, - }) - } - updateColumnMutation.mutate({ columnName, updates: { type: newType } }) + const previousType = column?.type + updateColumnMutation.mutate( + { columnName, updates: { type: newType } }, + { + onSuccess: () => { + if (previousType) { + pushUndoRef.current({ + type: 'update-column-type', + columnName, + previousType, + newType, + }) + } + }, + } + ) }, []) const insertColumnInOrder = useCallback( diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 282761df980..7b0d233d884 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -676,6 +676,9 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) { return res.json() }, + onError: (error) => { + toast.error(error.message, { duration: 5000 }) + }, onSettled: () => { invalidateTableSchema(queryClient, workspaceId, tableId) }, From ebf6938256602e0012be0e2b520b5c35371b5394 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 19:40:47 -0700 Subject: [PATCH 16/36] fix(tables): scroll-into-view for selection focus, Home/End origin, delete-column undo timing - Scroll-into-view now tracks selectionFocus (not just anchor), so Shift+Arrow extending selection off-screen properly auto-scrolls - Shift+Home/End now uses the current focus as origin (matching Shift+Arrow behavior) instead of always using anchor - Delete column undo entry is now pushed in onSuccess, preventing a corrupted undo stack if the server rejects the deletion - Dialog copy updated from "cannot be undone" to "You can undo this action" since undo/redo is supported Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 52 ++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 1f981601ecc..49da9f73d82 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -899,8 +899,9 @@ export function Table({ }, []) useEffect(() => { - if (!selectionAnchor) return - const { rowIndex, colIndex } = selectionAnchor + const target = selectionFocus ?? selectionAnchor + if (!target) return + const { rowIndex, colIndex } = target const rafId = requestAnimationFrame(() => { const cell = document.querySelector( `[data-table-scroll] [data-row="${rowIndex}"][data-col="${colIndex}"]` @@ -908,7 +909,7 @@ export function Table({ cell?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }) return () => cancelAnimationFrame(rafId) - }, [selectionAnchor]) + }, [selectionAnchor, selectionFocus]) const handleCellClick = useCallback((rowId: string, columnName: string) => { const column = columnsRef.current.find((c) => c.name === columnName) @@ -1172,7 +1173,8 @@ export function Table({ setIsColumnSelection(false) const jump = e.metaKey || e.ctrlKey if (e.shiftKey) { - setSelectionFocus({ rowIndex: jump ? 0 : anchor.rowIndex, colIndex: 0 }) + const focus = selectionFocusRef.current ?? anchor + setSelectionFocus({ rowIndex: jump ? 0 : focus.rowIndex, colIndex: 0 }) } else { setSelectionAnchor({ rowIndex: jump ? 0 : anchor.rowIndex, colIndex: 0 }) setSelectionFocus(null) @@ -1185,8 +1187,9 @@ export function Table({ setIsColumnSelection(false) const jump = e.metaKey || e.ctrlKey if (e.shiftKey) { + const focus = selectionFocusRef.current ?? anchor setSelectionFocus({ - rowIndex: jump ? totalRows - 1 : anchor.rowIndex, + rowIndex: jump ? totalRows - 1 : focus.rowIndex, colIndex: cols.length - 1, }) } else { @@ -1728,34 +1731,35 @@ export function Table({ const orderAtDelete = columnOrderRef.current const cols = schemaColumnsRef.current const colDef = cols.find((c) => c.name === columnToDelete) - const colPosition = cols.indexOf(colDef!) + const colPosition = colDef ? cols.indexOf(colDef) : cols.length const currentRows = rowsRef.current const cellData = currentRows .filter((r) => r.data[columnToDelete] != null) .map((r) => ({ rowId: r.id, value: r.data[columnToDelete] })) const previousWidth = columnWidthsRef.current[columnToDelete] ?? null - pushUndoRef.current({ - type: 'delete-column', - columnName: columnToDelete, - columnType: colDef?.type ?? 'string', - columnPosition: colPosition >= 0 ? colPosition : cols.length, - columnUnique: colDef?.unique ?? false, - cellData, - previousOrder: orderAtDelete ? [...orderAtDelete] : null, - previousWidth, - }) - setDeletingColumn(null) deleteColumnMutation.mutate(columnToDelete, { onSuccess: () => { - if (!orderAtDelete) return - const newOrder = orderAtDelete.filter((n) => n !== columnToDelete) - setColumnOrder(newOrder) - updateMetadataRef.current({ - columnWidths: columnWidthsRef.current, - columnOrder: newOrder, + pushUndoRef.current({ + type: 'delete-column', + columnName: columnToDelete, + columnType: colDef?.type ?? 'string', + columnPosition: colPosition >= 0 ? colPosition : cols.length, + columnUnique: colDef?.unique ?? false, + cellData, + previousOrder: orderAtDelete ? [...orderAtDelete] : null, + previousWidth, }) + + if (orderAtDelete) { + const newOrder = orderAtDelete.filter((n) => n !== columnToDelete) + setColumnOrder(newOrder) + updateMetadataRef.current({ + columnWidths: columnWidthsRef.current, + columnOrder: newOrder, + }) + } }, }) }, [deletingColumn]) @@ -2240,7 +2244,7 @@ export function Table({ This will remove all data in this column. {' '} - This action cannot be undone. + You can undo this action.

From d05bb81fa645b5fc40e17c1484000cc36dda7740 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 19:56:46 -0700 Subject: [PATCH 17/36] fix: resolve duplicate declarations from rebase against staging Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/workspace/[workspaceId]/home/home.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 5680f8efdd9..06d2d54c496 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -206,7 +206,7 @@ export function Home({ chatId }: HomeProps = {}) { workspace_id: workspaceId, view: 'mothership', }) - void stopGeneration().catch(() => {}) + stopGeneration() }, [stopGeneration, workspaceId]) const handleSubmit = useCallback( From 4005a1581fb2d2e06351640b73b266aa7e12bdf4 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 17 Apr 2026 20:01:17 -0700 Subject: [PATCH 18/36] fix file upload --- .../home/components/user-input/user-input.tsx | 5 +- .../user-input/hooks/use-file-attachments.ts | 124 +++++++++--------- 2 files changed, 67 insertions(+), 62 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index ce207f48a29..630c60ef1a7 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -158,6 +158,7 @@ export const UserInput = forwardRef(function Us isLoading: isSending, }) const hasFiles = files.attachedFiles.some((f) => !f.uploading && f.key) + const hasUploadingFiles = files.attachedFiles.some((f) => f.uploading) const contextManagement = useContextManagement({ message: value }) @@ -232,7 +233,7 @@ export const UserInput = forwardRef(function Us setSelectedContexts: contextManagement.setSelectedContexts, }) - const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending + const canSubmit = (value.trim().length > 0 || hasFiles) && !isSending && !hasUploadingFiles const valueRef = useRef(value) valueRef.current = value @@ -507,6 +508,8 @@ export const UserInput = forwardRef(function Us if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault() + // Mirror canSubmit's uploading guard; Enter reads refs, not rendered state. + if (filesRef.current.attachedFiles.some((f) => f.uploading)) return const hasSubmitPayload = valueRef.current.trim().length > 0 || filesRef.current.attachedFiles.some((file) => !file.uploading && file.key) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts index f449413794e..a7ac6ff729e 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments.ts @@ -104,8 +104,8 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { }, []) /** - * Processes and uploads files to S3 - * @param fileList - Files to process + * Uploads files in parallel so a slow file does not block faster ones queued + * behind it. All placeholders insert in a single state update for a stable row. */ const processFiles = useCallback( async (fileList: FileList) => { @@ -114,67 +114,69 @@ export function useFileAttachments(props: UseFileAttachmentsProps) { return } - for (const file of Array.from(fileList)) { - let previewUrl: string | undefined - if (file.type.startsWith('image/')) { - previewUrl = URL.createObjectURL(file) - } - - const resolvedType = resolveFileType(file) - - const tempFile: AttachedFile = { - id: generateId(), - name: file.name, - size: file.size, - type: resolvedType, - path: '', - uploading: true, - previewUrl, - } - - setAttachedFiles((prev) => [...prev, tempFile]) - - try { - const formData = new FormData() - formData.append('file', file) - formData.append('context', 'mothership') - if (workspaceId) { - formData.append('workspaceId', workspaceId) - } - - const uploadResponse = await fetch('/api/files/upload', { - method: 'POST', - body: formData, - }) - - if (!uploadResponse.ok) { - const errorData = await uploadResponse.json().catch(() => ({ - error: `Upload failed: ${uploadResponse.status}`, - })) - throw new Error(errorData.error || `Failed to upload file: ${uploadResponse.status}`) - } - - const uploadData = await uploadResponse.json() - - logger.info(`File uploaded successfully: ${uploadData.fileInfo?.path || uploadData.path}`) + const files = Array.from(fileList) + if (files.length === 0) return + + const placeholders: AttachedFile[] = files.map((file) => ({ + id: generateId(), + name: file.name, + size: file.size, + type: resolveFileType(file), + path: '', + uploading: true, + previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined, + })) + + setAttachedFiles((prev) => [...prev, ...placeholders]) + + await Promise.all( + files.map(async (file, i) => { + const placeholder = placeholders[i] + try { + const formData = new FormData() + formData.append('file', file) + formData.append('context', 'mothership') + if (workspaceId) { + formData.append('workspaceId', workspaceId) + } + + const uploadResponse = await fetch('/api/files/upload', { + method: 'POST', + body: formData, + }) + + if (!uploadResponse.ok) { + const errorData = await uploadResponse.json().catch(() => ({ + error: `Upload failed: ${uploadResponse.status}`, + })) + throw new Error(errorData.error || `Failed to upload file: ${uploadResponse.status}`) + } + + const uploadData = await uploadResponse.json() + + logger.info( + `File uploaded successfully: ${uploadData.fileInfo?.path || uploadData.path}` + ) - setAttachedFiles((prev) => - prev.map((f) => - f.id === tempFile.id - ? { - ...f, - path: uploadData.fileInfo?.path || uploadData.path || uploadData.url, - key: uploadData.fileInfo?.key || uploadData.key, - uploading: false, - } - : f + setAttachedFiles((prev) => + prev.map((f) => + f.id === placeholder.id + ? { + ...f, + path: uploadData.fileInfo?.path || uploadData.path || uploadData.url, + key: uploadData.fileInfo?.key || uploadData.key, + uploading: false, + } + : f + ) ) - ) - } catch (error) { - logger.error(`File upload failed: ${error}`) - setAttachedFiles((prev) => prev.filter((f) => f.id !== tempFile.id)) - } - } + } catch (error) { + logger.error(`File upload failed: ${error}`) + if (placeholder.previewUrl) URL.revokeObjectURL(placeholder.previewUrl) + setAttachedFiles((prev) => prev.filter((f) => f.id !== placeholder.id)) + } + }) + ) }, [userId, workspaceId] ) From 5c03acccae275128023a6256c97840d67238ab63 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 20:07:43 -0700 Subject: [PATCH 19/36] fix(tables): merge column widths on delete-column undo, try/finally for auto-resize - Delete-column undo now reads current column widths via getColumnWidths callback and merges the restored column's width into the full map, preventing other columns' widths from being wiped - Auto-resize measurement span is now wrapped in try/finally to ensure DOM cleanup if an exception occurs during measurement Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 29 +++++++++++-------- apps/sim/hooks/use-table-undo.ts | 6 +++- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 49da9f73d82..28b980a071e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -270,11 +270,14 @@ export function Table({ }) }, []) + const getColumnWidths = useCallback(() => columnWidthsRef.current, []) + const { pushUndo, undo, redo } = useTableUndo({ workspaceId, tableId, onColumnOrderChange: handleColumnOrderChange, onColumnRename: handleColumnRename, + getColumnWidths, }) const undoRef = useRef(undo) undoRef.current = undo @@ -757,20 +760,22 @@ export function Table({ measure.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit' document.body.appendChild(measure) - measure.className = 'font-medium text-small' - measure.textContent = columnName - maxWidth = Math.max(maxWidth, measure.offsetWidth + 36) - - measure.className = 'text-small' - for (const row of currentRows) { - const val = row.data[columnName] - if (val == null) continue - measure.textContent = String(val) - maxWidth = Math.max(maxWidth, measure.offsetWidth + 17) + try { + measure.className = 'font-medium text-small' + measure.textContent = columnName + maxWidth = Math.max(maxWidth, measure.offsetWidth + 36) + + measure.className = 'text-small' + for (const row of currentRows) { + const val = row.data[columnName] + if (val == null) continue + measure.textContent = String(val) + maxWidth = Math.max(maxWidth, measure.offsetWidth + 17) + } + } finally { + document.body.removeChild(measure) } - document.body.removeChild(measure) - const newWidth = Math.min(Math.ceil(maxWidth), 600) setColumnWidths((prev) => ({ ...prev, [columnName]: newWidth })) const updated = { ...columnWidthsRef.current, [columnName]: newWidth } diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index da6af4042a2..89adc460b7d 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -36,6 +36,7 @@ interface UseTableUndoProps { tableId: string onColumnOrderChange?: (order: string[]) => void onColumnRename?: (oldName: string, newName: string) => void + getColumnWidths?: () => Record } export function useTableUndo({ @@ -43,6 +44,7 @@ export function useTableUndo({ tableId, onColumnOrderChange, onColumnRename, + getColumnWidths, }: UseTableUndoProps) { const push = useTableUndoStore((s) => s.push) const popUndo = useTableUndoStore((s) => s.popUndo) @@ -69,6 +71,8 @@ export function useTableUndo({ onColumnOrderChangeRef.current = onColumnOrderChange const onColumnRenameRef = useRef(onColumnRename) onColumnRenameRef.current = onColumnRename + const getColumnWidthsRef = useRef(getColumnWidths) + getColumnWidthsRef.current = getColumnWidths useEffect(() => { return () => clear(tableId) @@ -230,7 +234,7 @@ export function useTableUndo({ ...(action.previousWidth !== null ? { columnWidths: { - ...({} as Record), + ...(getColumnWidthsRef.current?.() ?? {}), [action.columnName]: action.previousWidth, }, } From 46df4905f0cea4c22c310471be0dad11b7b993cf Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 20:08:34 -0700 Subject: [PATCH 20/36] fix: revert accidental home.tsx change from rebase conflict resolution Co-Authored-By: Claude Opus 4.6 --- apps/sim/app/workspace/[workspaceId]/home/home.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/home/home.tsx b/apps/sim/app/workspace/[workspaceId]/home/home.tsx index 06d2d54c496..5680f8efdd9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/home.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/home.tsx @@ -206,7 +206,7 @@ export function Home({ chatId }: HomeProps = {}) { workspace_id: workspaceId, view: 'mothership', }) - stopGeneration() + void stopGeneration().catch(() => {}) }, [stopGeneration, workspaceId]) const handleSubmit = useCallback( From 8eb28dac19ccc6e3d33ac7054e56c9f840ba968e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 20:16:50 -0700 Subject: [PATCH 21/36] fix(tables): clear isColumnSelection on double-click and right-click, skip scroll for column select - Clear isColumnSelection when double-clicking a cell to edit, preventing the column selection effect from fighting with the editing state - Clear isColumnSelection when right-clicking outside the current selection, preventing stale column selection from re-expanding - Skip scroll-into-view when isColumnSelection is true, preventing the viewport from jumping to the bottom row when clicking a column header Co-Authored-By: Claude Opus 4.6 --- .../tables/[tableId]/components/table/table.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 28b980a071e..d87669298d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -624,6 +624,7 @@ export function Table({ if (!isWithinSelection) { setSelectionAnchor({ rowIndex, colIndex }) setSelectionFocus(null) + setIsColumnSelection(false) } } } @@ -904,6 +905,7 @@ export function Table({ }, []) useEffect(() => { + if (isColumnSelection) return const target = selectionFocus ?? selectionAnchor if (!target) return const { rowIndex, colIndex } = target @@ -914,7 +916,7 @@ export function Table({ cell?.scrollIntoView({ block: 'nearest', inline: 'nearest' }) }) return () => cancelAnimationFrame(rafId) - }, [selectionAnchor, selectionFocus]) + }, [selectionAnchor, selectionFocus, isColumnSelection]) const handleCellClick = useCallback((rowId: string, columnName: string) => { const column = columnsRef.current.find((c) => c.name === columnName) @@ -939,6 +941,7 @@ export function Table({ if (!column || column.type === 'boolean') return setSelectionFocus(null) + setIsColumnSelection(false) setEditingCell({ rowId, columnName }) setInitialCharacter(null) }, []) From d1329858a721827b74ccd3feee97ec4830dc5bf8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 20:19:04 -0700 Subject: [PATCH 22/36] fix(tables): remove inline font override in auto-resize, guard undefined columnOrder - Remove `font:inherit` from measurement span inline style so Tailwind classes (font-medium, text-small) control font properties for accurate column width measurement - Only include columnOrder in metadata update when defined, preventing handleColumnRename from clearing a persisted column order when columnOrderRef is null Co-Authored-By: Claude Opus 4.6 --- .../[workspaceId]/tables/[tableId]/components/table/table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index d87669298d8..d38f599c57c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -266,7 +266,7 @@ export function Table({ if (updatedOrder) setColumnOrder(updatedOrder) updateMetadataRef.current({ columnWidths: updatedWidths, - columnOrder: updatedOrder, + ...(updatedOrder ? { columnOrder: updatedOrder } : {}), }) }, []) @@ -758,7 +758,7 @@ export function Table({ let maxWidth = COL_WIDTH_MIN const measure = document.createElement('span') - measure.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit' + measure.style.cssText = 'position:absolute;visibility:hidden;white-space:nowrap' document.body.appendChild(measure) try { From c4e5abf15cc65851f9daeeac488eb923ee9abd39 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 20:22:28 -0700 Subject: [PATCH 23/36] fix(tables): capture columnRequired in delete-column undo for full restoration The delete-column undo action captured columnUnique but not columnRequired, so undoing a delete on a required column would silently drop the constraint. Now captures and restores both constraints. Co-Authored-By: Claude Opus 4.6 --- .../[workspaceId]/tables/[tableId]/components/table/table.tsx | 1 + apps/sim/hooks/use-table-undo.ts | 1 + apps/sim/stores/table/types.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index d38f599c57c..233571049c2 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1755,6 +1755,7 @@ export function Table({ columnType: colDef?.type ?? 'string', columnPosition: colPosition >= 0 ? colPosition : cols.length, columnUnique: colDef?.unique ?? false, + columnRequired: colDef?.required ?? false, cellData, previousOrder: orderAtDelete ? [...orderAtDelete] : null, previousWidth, diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 89adc460b7d..42f88db5014 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -215,6 +215,7 @@ export function useTableUndo({ { name: action.columnName, type: action.columnType, + required: action.columnRequired, unique: action.columnUnique, position: action.columnPosition, }, diff --git a/apps/sim/stores/table/types.ts b/apps/sim/stores/table/types.ts index 2bc4ea1fe5d..8ff515681b0 100644 --- a/apps/sim/stores/table/types.ts +++ b/apps/sim/stores/table/types.ts @@ -38,6 +38,7 @@ export type TableUndoAction = columnType: string columnPosition: number columnUnique: boolean + columnRequired: boolean cellData: Array<{ rowId: string; value: unknown }> previousOrder: string[] | null previousWidth: number | null From 81f6791c250874ee288b188a3d657e376b42afc8 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 20:32:29 -0700 Subject: [PATCH 24/36] fix(tables): restore width independently of order on delete-column undo, batch fill-down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Column width restoration in delete-column undo no longer requires previousOrder to be non-null — width is restored independently - Ctrl+D fill-down now uses batchUpdateRef (single API call) instead of calling mutateRef per row in a loop Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 4 +++- apps/sim/hooks/use-table-undo.ts | 22 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index 233571049c2..e3240de49c9 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -1257,9 +1257,11 @@ export function Table({ } } undoCells.push({ rowId: row.id, oldData, newData }) - mutateRef.current({ rowId: row.id, data: newData }) } if (undoCells.length > 0) { + batchUpdateRef.current({ + updates: undoCells.map((c) => ({ rowId: c.rowId, data: c.newData })), + }) pushUndoRef.current({ type: 'update-cells', cells: undoCells }) } return diff --git a/apps/sim/hooks/use-table-undo.ts b/apps/sim/hooks/use-table-undo.ts index 42f88db5014..01e059e1f35 100644 --- a/apps/sim/hooks/use-table-undo.ts +++ b/apps/sim/hooks/use-table-undo.ts @@ -228,19 +228,19 @@ export function useTableUndo({ })) batchUpdateRowsMutation.mutate({ updates }) } + const metadata: Record = {} if (action.previousOrder) { onColumnOrderChangeRef.current?.(action.previousOrder) - updateMetadataMutation.mutate({ - columnOrder: action.previousOrder, - ...(action.previousWidth !== null - ? { - columnWidths: { - ...(getColumnWidthsRef.current?.() ?? {}), - [action.columnName]: action.previousWidth, - }, - } - : {}), - }) + metadata.columnOrder = action.previousOrder + } + if (action.previousWidth !== null) { + metadata.columnWidths = { + ...(getColumnWidthsRef.current?.() ?? {}), + [action.columnName]: action.previousWidth, + } + } + if (Object.keys(metadata).length > 0) { + updateMetadataMutation.mutate(metadata) } }, } From 754b39d1c57177ba6657e0b0877fcd4c158dcfda Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Fri, 17 Apr 2026 20:49:28 -0700 Subject: [PATCH 25/36] fix(tables): multi-column delete, select-all cell model, cut flash, chevron alignment - Multi-select delete: detect column selection range and delete all selected columns sequentially with individual undo entries - Select all (header checkbox): use cell selection model instead of checkbox model for consistent highlighting - Cut flash: batch cell clears into single mutation to prevent stale data flashing from multiple onSettled invalidations - Chevron alignment: adjust right padding from pr-2 to pr-2.5 Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 167 ++++++++++++------ 1 file changed, 110 insertions(+), 57 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index e3240de49c9..9f3b310186e 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -202,7 +202,7 @@ export function Table({ const lastCheckboxRowRef = useRef(null) const isColumnSelectionRef = useRef(false) const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) - const [deletingColumn, setDeletingColumn] = useState(null) + const [deletingColumns, setDeletingColumns] = useState(null) const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) const [columnWidths, setColumnWidths] = useState>({}) @@ -716,15 +716,16 @@ export function Table({ const handleSelectAllRows = useCallback(() => { const rws = rowsRef.current - if (rws.length === 0) return + const currentCols = columnsRef.current + if (rws.length === 0 || currentCols.length === 0) return setEditingCell(null) - setSelectionAnchor(null) - setSelectionFocus(null) - const all = new Set() - for (const row of rws) { - all.add(row.position) - } - setCheckedRows(all) + setCheckedRows((prev) => (prev.size === 0 ? prev : EMPTY_CHECKED_ROWS)) + setSelectionAnchor({ rowIndex: 0, colIndex: 0 }) + setSelectionFocus({ + rowIndex: maxPositionRef.current, + colIndex: currentCols.length - 1, + }) + setIsColumnSelection(false) scrollRef.current?.focus({ preventScroll: true }) }, []) @@ -1372,6 +1373,7 @@ export function Table({ const cols = columnsRef.current const pMap = positionMapRef.current const undoCells: Array<{ rowId: string; data: Record }> = [] + const batchUpdates: Array<{ rowId: string; data: Record }> = [] if (checked.size > 0) { e.preventDefault() @@ -1393,7 +1395,7 @@ export function Table({ updates[col.name] = null } undoCells.push({ rowId: row.id, data: previousData }) - mutateRef.current({ rowId: row.id, data: updates }) + batchUpdates.push({ rowId: row.id, data: updates }) } e.clipboardData?.setData('text/plain', lines.join('\n')) } else { @@ -1426,11 +1428,14 @@ export function Table({ } lines.push(cells.join('\t')) undoCells.push({ rowId: row.id, data: previousData }) - mutateRef.current({ rowId: row.id, data: updates }) + batchUpdates.push({ rowId: row.id, data: updates }) } e.clipboardData?.setData('text/plain', lines.join('\n')) } + if (batchUpdates.length > 0) { + batchUpdateRef.current({ updates: batchUpdates }) + } if (undoCells.length > 0) { pushUndoRef.current({ type: 'clear-cells', cells: undoCells }) } @@ -1732,48 +1737,76 @@ export function Table({ ) const handleDeleteColumn = useCallback((columnName: string) => { - setDeletingColumn(columnName) + const cols = columnsRef.current + if (isColumnSelectionRef.current && selectionAnchorRef.current) { + const sel = computeNormalizedSelection(selectionAnchorRef.current, selectionFocusRef.current) + if (sel && sel.startCol !== sel.endCol) { + const names: string[] = [] + for (let c = sel.startCol; c <= sel.endCol; c++) { + if (c < cols.length) names.push(cols[c].name) + } + if (names.length > 0) { + setDeletingColumns(names) + return + } + } + } + setDeletingColumns([columnName]) }, []) const handleDeleteColumnConfirm = useCallback(() => { - if (!deletingColumn) return - const columnToDelete = deletingColumn - const orderAtDelete = columnOrderRef.current - const cols = schemaColumnsRef.current - const colDef = cols.find((c) => c.name === columnToDelete) - const colPosition = colDef ? cols.indexOf(colDef) : cols.length - const currentRows = rowsRef.current - const cellData = currentRows - .filter((r) => r.data[columnToDelete] != null) - .map((r) => ({ rowId: r.id, value: r.data[columnToDelete] })) - const previousWidth = columnWidthsRef.current[columnToDelete] ?? null - - setDeletingColumn(null) - deleteColumnMutation.mutate(columnToDelete, { - onSuccess: () => { - pushUndoRef.current({ - type: 'delete-column', - columnName: columnToDelete, - columnType: colDef?.type ?? 'string', - columnPosition: colPosition >= 0 ? colPosition : cols.length, - columnUnique: colDef?.unique ?? false, - columnRequired: colDef?.required ?? false, - cellData, - previousOrder: orderAtDelete ? [...orderAtDelete] : null, - previousWidth, - }) - - if (orderAtDelete) { - const newOrder = orderAtDelete.filter((n) => n !== columnToDelete) - setColumnOrder(newOrder) - updateMetadataRef.current({ - columnWidths: columnWidthsRef.current, - columnOrder: newOrder, + if (!deletingColumns || deletingColumns.length === 0) return + const columnsToDelete = [...deletingColumns] + setDeletingColumns(null) + + let currentOrder = columnOrderRef.current ? [...columnOrderRef.current] : null + + const deleteNext = (index: number) => { + if (index >= columnsToDelete.length) return + const columnToDelete = columnsToDelete[index] + const cols = schemaColumnsRef.current + const colDef = cols.find((c) => c.name === columnToDelete) + const colPosition = colDef ? cols.indexOf(colDef) : cols.length + const currentRows = rowsRef.current + const cellData = currentRows + .filter((r) => r.data[columnToDelete] != null) + .map((r) => ({ rowId: r.id, value: r.data[columnToDelete] })) + const previousWidth = columnWidthsRef.current[columnToDelete] ?? null + const orderSnapshot = currentOrder ? [...currentOrder] : null + + deleteColumnMutation.mutate(columnToDelete, { + onSuccess: () => { + pushUndoRef.current({ + type: 'delete-column', + columnName: columnToDelete, + columnType: colDef?.type ?? 'string', + columnPosition: colPosition >= 0 ? colPosition : cols.length, + columnUnique: colDef?.unique ?? false, + columnRequired: colDef?.required ?? false, + cellData, + previousOrder: orderSnapshot, + previousWidth, }) - } - }, - }) - }, [deletingColumn]) + + if (currentOrder) { + currentOrder = currentOrder.filter((n) => n !== columnToDelete) + setColumnOrder(currentOrder) + updateMetadataRef.current({ + columnWidths: columnWidthsRef.current, + columnOrder: currentOrder, + }) + } + + deleteNext(index + 1) + }, + }) + } + + setSelectionAnchor(null) + setSelectionFocus(null) + setIsColumnSelection(false) + deleteNext(0) + }, [deletingColumns]) const handleSortChange = useCallback((column: string, direction: SortDirection) => { setQueryOptions((prev) => ({ ...prev, sort: { [column]: direction } })) @@ -2241,25 +2274,45 @@ export function Table({ )} { - if (!open) setDeletingColumn(null) + if (!open) setDeletingColumns(null) }} > - Delete Column + + {deletingColumns && deletingColumns.length > 1 + ? `Delete ${deletingColumns.length} Columns` + : 'Delete Column'} +

- Are you sure you want to delete{' '} - {deletingColumn}?{' '} + {deletingColumns && deletingColumns.length > 1 ? ( + <> + Are you sure you want to delete{' '} + + {deletingColumns.length} columns + + ?{' '} + + ) : ( + <> + Are you sure you want to delete{' '} + + {deletingColumns?.[0]} + + ?{' '} + + )} - This will remove all data in this column. + This will remove all data in{' '} + {deletingColumns && deletingColumns.length > 1 ? 'these columns' : 'this column'}. {' '} You can undo this action.

-
Date: Fri, 17 Apr 2026 19:11:06 -0700 Subject: [PATCH 10/36] fix(tables): preserve selection on right-click, auto-resize on double-click, fix escape during drag - Right-clicking within an existing selection now preserves it instead of resetting to a single cell, so context menu operations apply to the full range - Double-clicking a column border auto-resizes the column to fit its content - Escape during column drag now immediately clears refs before state update, preventing the dragend handler from executing the reorder Co-Authored-By: Claude Opus 4.6 --- .../[tableId]/components/table/table.tsx | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx index c2523be2e81..b6cfadf47ab 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx @@ -604,10 +604,24 @@ export function Table({ const rowIndex = Number.parseInt(td.getAttribute('data-row') || '-1', 10) const colIndex = Number.parseInt(td.getAttribute('data-col') || '-1', 10) if (rowIndex >= 0 && colIndex >= 0) { - setSelectionAnchor({ rowIndex, colIndex }) - setSelectionFocus(null) columnName = colIndex < columnsRef.current.length ? columnsRef.current[colIndex].name : null + + const sel = computeNormalizedSelection( + selectionAnchorRef.current, + selectionFocusRef.current + ) + const isWithinSelection = + sel !== null && + rowIndex >= sel.startRow && + rowIndex <= sel.endRow && + colIndex >= sel.startCol && + colIndex <= sel.endCol + + if (!isWithinSelection) { + setSelectionAnchor({ rowIndex, colIndex }) + setSelectionFocus(null) + } } } baseHandleRowContextMenu(e, row, columnName) @@ -731,6 +745,40 @@ export function Table({ updateMetadataRef.current({ columnWidths: columnWidthsRef.current }) }, []) + const handleColumnAutoResize = useCallback((columnName: string) => { + const cols = columnsRef.current + const colIndex = cols.findIndex((c) => c.name === columnName) + if (colIndex === -1) return + + const currentRows = rowsRef.current + let maxWidth = COL_WIDTH_MIN + + const measure = document.createElement('span') + measure.style.cssText = + 'position:absolute;visibility:hidden;white-space:nowrap;font:inherit;padding:0 8px' + document.body.appendChild(measure) + + measure.className = 'font-medium text-small' + measure.textContent = columnName + maxWidth = Math.max(maxWidth, measure.offsetWidth + 48) + + measure.className = 'text-small' + for (const row of currentRows) { + const val = row.data[columnName] + if (val == null) continue + measure.textContent = String(val) + maxWidth = Math.max(maxWidth, measure.offsetWidth + 20) + } + + document.body.removeChild(measure) + + const newWidth = Math.min(Math.ceil(maxWidth), 600) + setColumnWidths((prev) => ({ ...prev, [columnName]: newWidth })) + const updated = { ...columnWidthsRef.current, [columnName]: newWidth } + columnWidthsRef.current = updated + updateMetadataRef.current({ columnWidths: updated }) + }, []) + const handleColumnDragStart = useCallback((columnName: string) => { setDragColumnName(columnName) }, []) @@ -928,6 +976,9 @@ export function Table({ if (e.key === 'Escape') { e.preventDefault() if (dragColumnNameRef.current) { + dragColumnNameRef.current = null + dropTargetColumnNameRef.current = null + dropSideRef.current = 'left' setDragColumnName(null) setDropTargetColumnName(null) setDropSide('left') @@ -1957,6 +2008,7 @@ export function Table({ onResizeStart={handleColumnResizeStart} onResize={handleColumnResize} onResizeEnd={handleColumnResizeEnd} + onAutoResize={handleColumnAutoResize} onDragStart={handleColumnDragStart} onDragOver={handleColumnDragOver} onDragEnd={handleColumnDragEnd} @@ -2880,6 +2932,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResizeStart, onResize, onResizeEnd, + onAutoResize, onDragStart, onDragOver, onDragEnd, @@ -2904,6 +2957,7 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onResizeStart: (columnName: string) => void onResize: (columnName: string, width: number) => void onResizeEnd: () => void + onAutoResize: (columnName: string) => void onDragStart?: (columnName: string) => void onDragOver?: (columnName: string, side: 'left' | 'right') => void onDragEnd?: () => void @@ -3150,6 +3204,11 @@ const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ draggable={false} onDragStart={(e) => e.stopPropagation()} onPointerDown={handleResizePointerDown} + onDoubleClick={(e) => { + e.preventDefault() + e.stopPropagation() + onAutoResize(column.name) + }} />