@@ -6,7 +6,7 @@ import { createLogger } from '@sim/logger'
66import { useVirtualizer } from '@tanstack/react-virtual'
77import { useParams } from 'next/navigation'
88import { usePostHog } from 'posthog-js/react'
9- import { Skeleton , toast } from '@/components/emcn'
9+ import { Skeleton , toast , useToast } from '@/components/emcn'
1010import { TableX } from '@/components/emcn/icons'
1111import type { RunMode } from '@/lib/api/contracts/tables'
1212import { cn } from '@/lib/core/utils/cn'
@@ -317,6 +317,9 @@ export function TableGrid({
317317 const totalRunning = tableRunState ?. runningCellCount ?? 0
318318 const runningByRowId = tableRunState ?. runningByRowId ?? EMPTY_RUNNING_BY_ROW
319319
320+ const tableRowCountRef = useRef ( tableData ?. rowCount ?? 0 )
321+ tableRowCountRef . current = tableData ?. rowCount ?? 0
322+
320323 const fetchNextPageRef = useRef ( fetchNextPage )
321324 fetchNextPageRef . current = fetchNextPage
322325 const hasNextPageRef = useRef ( hasNextPage )
@@ -356,7 +359,8 @@ export function TableGrid({
356359 useLayoutEffect ( ( ) => {
357360 const el = theadRef . current
358361 if ( ! el ) return
359- const measure = ( ) => setHeaderHeight ( el . offsetHeight )
362+ const measure = ( ) =>
363+ setHeaderHeight ( ( prev ) => ( prev === el . offsetHeight ? prev : el . offsetHeight ) )
360364 measure ( )
361365 const observer = new ResizeObserver ( measure )
362366 observer . observe ( el )
@@ -374,6 +378,9 @@ export function TableGrid({
374378 const userPermissions = useUserPermissionsContext ( )
375379 const canEditRef = useRef ( userPermissions . canEdit )
376380 canEditRef . current = userPermissions . canEdit
381+ const { dismiss : dismissToast } = useToast ( )
382+ const dismissToastRef = useRef ( dismissToast )
383+ dismissToastRef . current = dismissToast
377384 // Refs for callback props read inside effects with stable empty deps.
378385 const onOpenRowModalRef = useRef ( onOpenRowModal )
379386 onOpenRowModalRef . current = onOpenRowModal
@@ -1483,26 +1490,46 @@ export function TableGrid({
14831490 if ( ! target ) return
14841491 const { rowIndex, colIndex } = target
14851492 const selector = `[data-table-scroll] [data-row="${ rowIndex } "][data-col="${ colIndex } "]`
1493+ // `scrollIntoView` ignores the sticky `<thead>` and sticky gutter, so a cell
1494+ // scrolled to the edge lands behind them. Scroll manually with insets equal
1495+ // to the sticky header height (top) and the row-number column width (left).
1496+ const revealCell = ( cell : HTMLElement ) => {
1497+ const scrollEl = scrollRef . current
1498+ if ( ! scrollEl ) return
1499+ const view = scrollEl . getBoundingClientRect ( )
1500+ const rect = cell . getBoundingClientRect ( )
1501+ const topInset = theadRef . current ?. offsetHeight ?? 0
1502+ if ( rect . top < view . top + topInset ) {
1503+ scrollEl . scrollTop -= view . top + topInset - rect . top
1504+ } else if ( rect . bottom > view . bottom ) {
1505+ scrollEl . scrollTop += rect . bottom - view . bottom
1506+ }
1507+ if ( rect . left < view . left + checkboxColWidth ) {
1508+ scrollEl . scrollLeft -= view . left + checkboxColWidth - rect . left
1509+ } else if ( rect . right > view . right ) {
1510+ scrollEl . scrollLeft += rect . right - view . right
1511+ }
1512+ }
14861513 let secondRaf = 0
14871514 const rafId = requestAnimationFrame ( ( ) => {
14881515 const cell = document . querySelector ( selector ) as HTMLElement | null
14891516 if ( cell ) {
1490- cell . scrollIntoView ( { block : 'nearest' , inline : 'nearest' } )
1517+ revealCell ( cell )
14911518 return
14921519 }
14931520 // Target row is windowed out (large jump / PageUp-Down). Bring it into the
1494- // virtualized range first, then align horizontally once it has rendered.
1521+ // virtualized range first, then align once it has rendered.
14951522 rowVirtualizer . scrollToIndex ( rowIndex , { align : 'auto' } )
14961523 secondRaf = requestAnimationFrame ( ( ) => {
14971524 const rendered = document . querySelector ( selector ) as HTMLElement | null
1498- rendered ?. scrollIntoView ( { block : 'nearest' , inline : 'nearest' } )
1525+ if ( rendered ) revealCell ( rendered )
14991526 } )
15001527 } )
15011528 return ( ) => {
15021529 cancelAnimationFrame ( rafId )
15031530 if ( secondRaf ) cancelAnimationFrame ( secondRaf )
15041531 }
1505- } , [ selectionAnchor , selectionFocus , isColumnSelection , rowVirtualizer ] )
1532+ } , [ selectionAnchor , selectionFocus , isColumnSelection , rowVirtualizer , checkboxColWidth ] )
15061533
15071534 const handleCellClick = useCallback (
15081535 ( rowId : string , columnName : string , options ?: { toggleBoolean ?: boolean } ) => {
@@ -1973,13 +2000,20 @@ export function TableGrid({
19732000 selectRow : ( row : TableRowType ) => boolean
19742001 buildCells : ( row : TableRowType ) => string [ ]
19752002 verb : 'Copied' | 'Cut'
2003+ /** Best-known row count for the in-progress toast (exact count is shown on completion). */
2004+ estimatedCount : number
19762005 afterCopy ?: ( copiedRows : TableRowType [ ] ) => Promise < void > | void
19772006 } ) => {
19782007 if ( typeof ClipboardItem === 'undefined' || ! navigator . clipboard ) {
19792008 toast . error ( 'Clipboard access is unavailable in this context' )
19802009 return
19812010 }
1982- toast ( { message : `${ opts . verb === 'Copied' ? 'Copying' : 'Cutting' } … loading rows` } )
2011+ const isCopy = opts . verb === 'Copied'
2012+ const verbLower = isCopy ? 'copy' : 'cut'
2013+ const estimate = opts . estimatedCount
2014+ const loadingToastId = toast ( {
2015+ message : `${ isCopy ? 'Copying' : 'Cutting' } ${ estimate . toLocaleString ( ) } ${ estimate === 1 ? 'row' : 'rows' } …` ,
2016+ } )
19832017 let rowCount = 0
19842018 let truncated = false
19852019 const copiedRows : TableRowType [ ] = [ ]
@@ -1999,27 +2033,45 @@ export function TableGrid({
19992033 rowCount = lines . length
20002034 return new Blob ( [ lines . join ( '\n' ) ] , { type : 'text/plain' } )
20012035 } ) ( )
2002- void navigator . clipboard
2003- . write ( [ new ClipboardItem ( { 'text/plain' : blob } ) ] )
2004- . then ( async ( ) => {
2036+ // `.write()` is invoked synchronously so the copy/cut gesture's transient
2037+ // activation survives the async row load inside the blob promise.
2038+ const writePromise = navigator . clipboard . write ( [ new ClipboardItem ( { 'text/plain' : blob } ) ] )
2039+ void ( async ( ) => {
2040+ try {
2041+ await writePromise
2042+ } catch ( error ) {
2043+ // Rejects if the row load failed or the payload is too large for the
2044+ // clipboard — either way nothing landed, so report a plain failure
2045+ // rather than implying a size cap was hit.
2046+ logger . error ( `Failed to ${ verbLower } rows` , { error } )
2047+ dismissToastRef . current ( loadingToastId )
2048+ toast . error ( `Failed to ${ verbLower } — please try again` )
2049+ return
2050+ }
2051+ // The clipboard now holds the data; a clear failure must not be reported
2052+ // as a copy/cut failure.
2053+ try {
20052054 await opts . afterCopy ?.( copiedRows )
2006- if ( truncated ) {
2007- toast ( {
2008- message : `${ opts . verb } first ${ TABLE_LIMITS . MAX_COPY_ROWS . toLocaleString ( ) } rows — export to CSV for the rest` ,
2009- } )
2010- } else {
2011- toast . success (
2012- `${ opts . verb } ${ rowCount . toLocaleString ( ) } ${ rowCount === 1 ? 'row' : 'rows' } `
2013- )
2014- }
2015- } )
2016- . catch ( ( error ) => {
2017- logger . error ( `Failed to ${ opts . verb === 'Copied' ? 'copy' : 'cut' } rows` , { error } )
2018- toast . error ( 'Selection too large to copy — use Export CSV' )
2019- } )
2055+ } catch ( error ) {
2056+ logger . error ( 'Failed to clear cut cells' , { error } )
2057+ dismissToastRef . current ( loadingToastId )
2058+ toast . error ( 'Copied to clipboard, but clearing the cells failed — please try again' )
2059+ return
2060+ }
2061+ dismissToastRef . current ( loadingToastId )
2062+ if ( truncated ) {
2063+ toast ( {
2064+ message : `${ opts . verb } first ${ TABLE_LIMITS . MAX_COPY_ROWS . toLocaleString ( ) } rows — export to CSV for the rest` ,
2065+ } )
2066+ } else {
2067+ toast . success (
2068+ `${ opts . verb } ${ rowCount . toLocaleString ( ) } ${ rowCount === 1 ? 'row' : 'rows' } `
2069+ )
2070+ }
2071+ } ) ( )
20202072 }
20212073
2022- /** Clears `colNames` on `rowsToClear` (the cut tail) and records an undo entry . */
2074+ /** Clears `colNames` on `rowsToClear` (the cut tail), recording undo only after the update lands . */
20232075 const clearCutRows = async ( rowsToClear : TableRowType [ ] , colNames : string [ ] ) => {
20242076 const undo : Array < { rowId : string ; data : Record < string , unknown > } > = [ ]
20252077 const updates : Array < { rowId : string ; data : Record < string , unknown > } > = [ ]
@@ -2033,8 +2085,8 @@ export function TableGrid({
20332085 undo . push ( { rowId : row . id , data : previousData } )
20342086 updates . push ( { rowId : row . id , data : nextData } )
20352087 }
2036- if ( undo . length > 0 ) pushUndoRef . current ( { type : 'clear-cells' , cells : undo } )
20372088 if ( updates . length > 0 ) await chunkBatchUpdates ( updates , batchUpdateAsyncRef . current )
2089+ if ( undo . length > 0 ) pushUndoRef . current ( { type : 'clear-cells' , cells : undo } )
20382090 }
20392091
20402092 const handleCopy = ( e : ClipboardEvent ) => {
@@ -2056,6 +2108,7 @@ export function TableGrid({
20562108 selectRow : ( row ) => rowSelectionIncludes ( rowSel , row . id ) ,
20572109 buildCells : ( row ) => cols . map ( ( col ) => cellToText ( row . data [ col . name ] ) ) ,
20582110 verb : 'Copied' ,
2111+ estimatedCount : rowSel . kind === 'some' ? rowSel . ids . size : tableRowCountRef . current ,
20592112 } )
20602113 return
20612114 }
@@ -2069,19 +2122,17 @@ export function TableGrid({
20692122 e . preventDefault ( )
20702123
20712124 if ( isColumnSelectionRef . current ) {
2125+ const colNames : string [ ] = [ ]
2126+ for ( let c = sel . startCol ; c <= sel . endCol ; c ++ ) {
2127+ const name = cols [ c ] ?. name
2128+ if ( name ) colNames . push ( name )
2129+ }
20722130 writeSelectionToClipboard ( {
20732131 loadRows : ( ) => ensureRowsLoadedUpToRef . current ( TABLE_LIMITS . MAX_COPY_ROWS ) ,
20742132 selectRow : ( ) => true ,
2075- buildCells : ( row ) => {
2076- const cells : string [ ] = [ ]
2077- for ( let c = sel . startCol ; c <= sel . endCol ; c ++ ) {
2078- const colName = cols [ c ] ?. name
2079- if ( ! colName ) continue
2080- cells . push ( cellToText ( row . data [ colName ] ) )
2081- }
2082- return cells
2083- } ,
2133+ buildCells : ( row ) => colNames . map ( ( name ) => cellToText ( row . data [ name ] ) ) ,
20842134 verb : 'Copied' ,
2135+ estimatedCount : tableRowCountRef . current ,
20852136 } )
20862137 return
20872138 }
@@ -2119,6 +2170,7 @@ export function TableGrid({
21192170 selectRow : ( row ) => rowSelectionIncludes ( rowSel , row . id ) ,
21202171 buildCells : ( row ) => cols . map ( ( col ) => cellToText ( row . data [ col . name ] ) ) ,
21212172 verb : 'Cut' ,
2173+ estimatedCount : rowSel . kind === 'some' ? rowSel . ids . size : tableRowCountRef . current ,
21222174 afterCopy : ( copied ) =>
21232175 clearCutRows (
21242176 copied ,
@@ -2147,6 +2199,7 @@ export function TableGrid({
21472199 selectRow : ( ) => true ,
21482200 buildCells : ( row ) => colNames . map ( ( name ) => cellToText ( row . data [ name ] ) ) ,
21492201 verb : 'Cut' ,
2202+ estimatedCount : tableRowCountRef . current ,
21502203 afterCopy : ( copied ) => clearCutRows ( copied , colNames ) ,
21512204 } )
21522205 return
0 commit comments