Skip to content

Commit 36a986e

Browse files
committed
feat(tabs): slide-aside on drag — siblings make room for the dragged tab
Browser-tab UX polish: while a tab is being dragged horizontally, sibling tabs translateX by ±slotWidth to visually open up the slot where the dragged tab will land if released. With `transform` now part of the .chrome-tab transition, the shifts ease in smoothly (0.18s) instead of snapping. The math: - tabDrag captures fromIdx (visible-strip), targetIdx (without- dragged-strip insertion point), and slotWidth (dragged tab's center-to-neighbor-center distance, which already includes the flex `gap`). - On every pointermove, computeTargetIdx() walks positions[] and returns the count of siblings with center <= cursor's draggedCenter — same insertion-index semantics the reducer uses. - In render, each non-dragged tab maps its visible-index `j` to the without-dragged index (`j` if j < fromIdx, else `j - 1`) and compares to targetIdx to decide left / right / no shift. The dragged tab itself keeps `transition: none` (.chrome-tab.dragging) so it tracks the cursor 1:1; only siblings animate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 27a0b76 commit 36a986e

2 files changed

Lines changed: 113 additions & 24 deletions

File tree

src-ui/src/components/center/CenterPanel.css

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@
3535
user-select: none;
3636
flex: 0 1 auto;
3737
max-width: 220px;
38-
transition: background 0.15s, color 0.15s;
38+
/* `transform` is part of the transition so siblings ease into their
39+
* shifted positions when another tab is being dragged past them
40+
* (browser-style slide-aside). The dragged tab itself overrides
41+
* this with `transition: none` via `.dragging` so it tracks the
42+
* cursor 1:1. */
43+
transition: background 0.15s, color 0.15s, transform 0.18s ease;
3944
}
4045

4146

src-ui/src/components/center/CenterPanel.tsx

Lines changed: 107 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)