@@ -823,10 +823,19 @@ export function CenterPanel() {
823823 // Tauri v2 + WebView2 swallows intra-app dragstart on Windows when its
824824 // own file-drop capture is enabled (memory: reference_webview2_html5_drag).
825825 const tabsHeaderRef = useRef < HTMLDivElement | null > ( null ) ;
826- // `dragging` set during an active drag → applies translateX to the
827- // dragged tab and `.dragging` class for elevation/opacity styling.
828- // `null` when no drag in progress.
829- const [ tabDrag , setTabDrag ] = useState < { sessionId : string ; deltaX : number } | null > ( null ) ;
826+ // Active drag state. `fromIdx` / `targetIdx` are positions in the
827+ // **visible tab strip** (filtered, in DOM order) — not the underlying
828+ // `state.terminals` array — because the slide-aside math operates on
829+ // what the user actually sees. `slotWidth` is the dragged tab's
830+ // visual footprint (own width + the flex `gap`); siblings translateX
831+ // by ±slotWidth to make room.
832+ const [ tabDrag , setTabDrag ] = useState < {
833+ sessionId : string ;
834+ deltaX : number ;
835+ fromIdx : number ;
836+ targetIdx : number ;
837+ slotWidth : number ;
838+ } | null > ( null ) ;
830839 // Suppress the click that would otherwise fire on pointerup at end of
831840 // a drag (the click handler activates the tab — we don't want a drop
832841 // to count as an activation if the tab moved).
@@ -840,28 +849,68 @@ export function CenterPanel() {
840849 const headerEl = tabsHeaderRef . current ;
841850 if ( ! headerEl ) return ;
842851
843- // Snapshot every visible tab's center X at drag start. Used by drop
844- // to compute target position from cursor X without re-measuring
845- // (DOM is shifting during drag → measurements would be unstable) .
852+ // Snapshot every visible tab's center X at drag start. Used by
853+ // drop AND by the shift math during drag — DOM is animating
854+ // during drag, so re-measuring would feed back into itself .
846855 const tabEls = Array . from (
847856 headerEl . querySelectorAll < HTMLElement > ( '.chrome-tab[data-session-id]' ) ,
848857 ) ;
849858 const positions = tabEls . map ( el => {
850859 const rect = el . getBoundingClientRect ( ) ;
851- return { sessionId : el . dataset . sessionId ! , center : rect . left + rect . width / 2 } ;
860+ return {
861+ sessionId : el . dataset . sessionId ! ,
862+ center : rect . left + rect . width / 2 ,
863+ width : rect . width ,
864+ } ;
852865 } ) ;
853- const ownPos = positions . find ( p => p . sessionId === sessionId ) ;
854- if ( ! ownPos ) return ;
866+ const fromIdx = positions . findIndex ( p => p . sessionId === sessionId ) ;
867+ if ( fromIdx < 0 ) return ;
868+ const ownPos = positions [ fromIdx ] ;
869+
870+ // Dragged tab's "occupied slot width" = distance from its center to
871+ // its nearest neighbor's center. This includes the flex `gap`
872+ // between tabs, so siblings shifting by slotWidth visually fill
873+ // the vacated slot exactly.
874+ let slotWidth : number ;
875+ if ( fromIdx + 1 < positions . length ) {
876+ slotWidth = positions [ fromIdx + 1 ] . center - ownPos . center ;
877+ } else if ( fromIdx > 0 ) {
878+ slotWidth = ownPos . center - positions [ fromIdx - 1 ] . center ;
879+ } else {
880+ slotWidth = ownPos . width ; // only one tab — no siblings to shift anyway
881+ }
855882
856883 const startX = e . clientX ;
857884 let started = false ;
858885 const THRESHOLD = 5 ;
859886
887+ // For a given cursor X, what's the without-dragged-array index that
888+ // the dragged tab would land on? Derived from the dragged tab's
889+ // visual center vs every OTHER tab's recorded center. Returns a
890+ // value in [0, positions.length - 1] — same domain as the
891+ // `insertIdx` the reducer uses.
892+ const computeTargetIdx = ( clientX : number ) : number => {
893+ const draggedCenter = ownPos . center + ( clientX - startX ) ;
894+ let count = 0 ;
895+ for ( let i = 0 ; i < positions . length ; i ++ ) {
896+ if ( i === fromIdx ) continue ;
897+ if ( positions [ i ] . center > draggedCenter ) return count ;
898+ count ++ ;
899+ }
900+ return count ;
901+ } ;
902+
860903 const onMove = ( ev : PointerEvent ) => {
861904 const dx = ev . clientX - startX ;
862905 if ( ! started && Math . abs ( dx ) < THRESHOLD ) return ;
863906 started = true ;
864- setTabDrag ( { sessionId, deltaX : dx } ) ;
907+ setTabDrag ( {
908+ sessionId,
909+ deltaX : dx ,
910+ fromIdx,
911+ targetIdx : computeTargetIdx ( ev . clientX ) ,
912+ slotWidth,
913+ } ) ;
865914 } ;
866915
867916 const onUp = ( ev : PointerEvent ) => {
@@ -871,15 +920,11 @@ export function CenterPanel() {
871920 // Suppress the upcoming click (tab activation) — the user dragged,
872921 // they didn't click. Cleared on the next click that fires.
873922 tabDragSuppressClickRef . current = true ;
874- // Visual center of dragged tab at drop = original center + dx
875- const draggedCenter = ownPos . center + ( ev . clientX - startX ) ;
876- // beforeId = first OTHER tab whose center is past the dragged
877- // center; null = drop at end (cursor is past every other tab).
878- const others = positions . filter ( p => p . sessionId !== sessionId ) ;
879- let beforeId : string | null = null ;
880- for ( const o of others ) {
881- if ( o . center > draggedCenter ) { beforeId = o . sessionId ; break ; }
882- }
923+ const targetIdx = computeTargetIdx ( ev . clientX ) ;
924+ // beforeId = the tab at `targetIdx` in the without-dragged strip;
925+ // `null` when dropping past every other tab (insert at end).
926+ const others = positions . filter ( ( _ , i ) => i !== fromIdx ) ;
927+ const beforeId = targetIdx < others . length ? others [ targetIdx ] . sessionId : null ;
883928 dispatch ( { type : 'REORDER_TERMINAL' , sessionId, beforeId } ) ;
884929 }
885930 setTabDrag ( null ) ;
@@ -1115,15 +1160,53 @@ export function CenterPanel() {
11151160 document . body
11161161 ) }
11171162 < div ref = { tabsHeaderRef } className = "chrome-tabs-header" data-count = { terminals . filter ( s => ! s . isHidden || s . id === activeTerminalId ) . length } >
1118- { terminals . map ( session => {
1163+ { ( ( ) => {
1164+ // Pre-compute visible-strip index for each session so the inner
1165+ // map can do O(1) shift lookups without re-filtering.
1166+ const visibleIdxBySid = new Map < string , number > ( ) ;
1167+ let v = 0 ;
1168+ for ( const s of terminals ) {
1169+ if ( s . isHidden && s . id !== activeTerminalId ) continue ;
1170+ visibleIdxBySid . set ( s . id , v ++ ) ;
1171+ }
1172+ return terminals . map ( session => {
11191173 if ( session . isHidden && session . id !== activeTerminalId ) return null ;
11201174
11211175 const isActive = session . id === activeTerminalId ;
11221176 const { icon, title } = renderTabContent ( session , isActive ) ;
11231177 const isDragging = tabDrag ?. sessionId === session . id ;
1178+
1179+ // Sibling shift: while a different tab is being dragged, this
1180+ // tab translateX-es by ±slotWidth to make room for / fill in
1181+ // the dragged tab's vacated slot. Browser-tab "slide aside".
1182+ //
1183+ // Math: in the visible-strip's without-dragged array, the
1184+ // dragged tab will land at position `targetIdx`. Each sibling
1185+ // at original visible-index `j` (j !== fromIdx) maps to a
1186+ // without-dragged index of either `j` (if j < fromIdx) or
1187+ // `j - 1` (if j > fromIdx). Compare that to targetIdx:
1188+ // - if j < fromIdx and its without-idx < targetIdx → no shift
1189+ // - if j < fromIdx and its without-idx >= targetIdx → shift right (+slotWidth)
1190+ // - if j > fromIdx and its without-idx < targetIdx → shift left (-slotWidth)
1191+ // - if j > fromIdx and its without-idx >= targetIdx → no shift
1192+ let siblingShift = 0 ;
1193+ if ( tabDrag && ! isDragging ) {
1194+ const j = visibleIdxBySid . get ( session . id ) ;
1195+ if ( j !== undefined ) {
1196+ const withoutIdx = j < tabDrag . fromIdx ? j : j - 1 ;
1197+ if ( j < tabDrag . fromIdx && withoutIdx >= tabDrag . targetIdx ) {
1198+ siblingShift = tabDrag . slotWidth ;
1199+ } else if ( j > tabDrag . fromIdx && withoutIdx < tabDrag . targetIdx ) {
1200+ siblingShift = - tabDrag . slotWidth ;
1201+ }
1202+ }
1203+ }
1204+
11241205 const dragStyle : React . CSSProperties | undefined = isDragging
11251206 ? { transform : `translateX(${ tabDrag ! . deltaX } px)` }
1126- : undefined ;
1207+ : siblingShift !== 0
1208+ ? { transform : `translateX(${ siblingShift } px)` }
1209+ : undefined ;
11271210
11281211 return (
11291212 < div
@@ -1177,7 +1260,8 @@ export function CenterPanel() {
11771260 </ div >
11781261 </ div >
11791262 ) ;
1180- } ) }
1263+ } ) ;
1264+ } ) ( ) }
11811265
11821266 < button className = "chrome-tab-new" onClick = { handleAddTab } >
11831267 < svg width = "16" height = "16" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" strokeWidth = "2.5" strokeLinecap = "round" strokeLinejoin = "round" > < line x1 = "12" y1 = "5" x2 = "12" y2 = "19" > </ line > < line x1 = "5" y1 = "12" x2 = "19" y2 = "12" > </ line > </ svg >
0 commit comments