import { useEffect, useRef, useState, type DragEvent, type MouseEvent, } from 'react'; import { PlusIcon, XIcon } from '../../icons'; import type { DocumentSlotState } from '../../documents/useDocumentSlot'; import { useContextMenu, type ContextMenuItem, } from '../../platform/contextMenu'; import type { TileTab } from './tabsModel'; import { hasTabDrag, readTabDrag, setTabDrag } from './tabDnd'; import './TileTabStrip.css'; export type TabAction = | 'close' | 'close-others' | 'close-right' | 'close-left' | 'close-all-of-file'; function tabTitle(slot: DocumentSlotState): string { // Empty tabs are never rendered as chips (see below), so this only ever // formats a tab that holds a document request. return slot.kind === 'empty' ? '' : slot.request.title; } type TileTabStripProps = { tileId: string; tabs: readonly TileTab[]; activeTabId: string | null; onSelect: (tabId: string) => void; onTabAction: (action: TabAction, tabId: string) => void; /** Drop a tab (from `fromTileId`) into this tile before `beforeTabId`. */ onMoveTab: ( fromTileId: string, tabId: string, beforeTabId: string | null, ) => void; onNew: () => void; }; /** * Horizontal tab strip for a single tile. Empty tabs are the tile's start page, * not chips, so only documents get a tab. Chips can be dragged to reorder or to * move between tiles; right-click opens a native context menu of tab actions. */ export function TileTabStrip({ tileId, tabs, activeTabId, onSelect, onTabAction, onMoveTab, onNew, }: TileTabStripProps) { const showContextMenu = useContextMenu(); // The horizontal scroll container. A ref (not just the class) so we can attach // a non-passive wheel listener — React registers `onWheel` as passive, which // silently drops preventDefault(). const stripRef = useRef(null); // Let a plain vertical mouse wheel scroll the tab strip sideways while the // pointer is over it. Trackpads already emit deltaX for horizontal gestures, // so we drive scrollLeft from whichever axis the user pushed hardest. useEffect(() => { const el = stripRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { // Nothing overflows horizontally — leave the event alone so it can scroll // an ancestor as usual. if (el.scrollWidth <= el.clientWidth) return; const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; if (delta === 0) return; el.scrollLeft += delta; e.preventDefault(); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); // Insertion point shown while dragging: undefined = no drag in progress, // string = before that tab, null = at the end. const [dropBefore, setDropBefore] = useState( undefined, ); // Empty tabs are the tile's start page, not their own chip. const documentTabs = tabs.filter((tab) => tab.slot.kind !== 'empty'); const isTabDrag = (e: DragEvent) => hasTabDrag(e.dataTransfer); const handleAuxClose = (event: MouseEvent, tabId: string) => { // Middle-click closes the tab. if (event.button === 1) { event.preventDefault(); onTabAction('close', tabId); } }; const handleContextMenu = async (event: MouseEvent, tabId: string) => { event.preventDefault(); // Index/length use the full tab list to match the model's close ops. const index = tabs.findIndex((t) => t.tabId === tabId); const items: ContextMenuItem[] = [{ id: 'close', label: 'Close' }]; if (tabs.length > 1) items.push({ id: 'close-others', label: 'Close others' }); if (index < tabs.length - 1) { items.push({ id: 'close-right', label: 'Close to the right' }); } if (index > 0) items.push({ id: 'close-left', label: 'Close to the left' }); items.push({ id: 'sep', label: '', type: 'separator' }); items.push({ id: 'close-all-of-file', label: 'Close all of this file' }); const action = await showContextMenu(items); if (action && action !== 'sep') { onTabAction(action as TabAction, tabId); } }; const handleDragStart = (e: DragEvent, tabId: string) => { setTabDrag(e.dataTransfer, { tileId, tabId }); // Snapshotting the live chip is unreliable: it can be clipped by the tile's // overflow/border and pull in neighbouring strip content. Instead render an // independent, unclipped clone off-screen and use that as the drag image. const chip = e.currentTarget as HTMLElement; const rect = chip.getBoundingClientRect(); const ghost = chip.cloneNode(true) as HTMLElement; Object.assign(ghost.style, { position: 'fixed', top: '-1000px', left: '0', width: `${rect.width}px`, height: `${rect.height}px`, pointerEvents: 'none', backgroundColor: 'var(--color-background)', border: '1px solid var(--color-border)', borderRadius: '6px', boxShadow: '0 2px 8px rgb(0 0 0 / 0.25)', opacity: '0.95', }); document.body.appendChild(ghost); e.dataTransfer.setDragImage( ghost, e.clientX - rect.left, e.clientY - rect.top, ); // Remove after the browser has captured the image (next task). window.setTimeout(() => ghost.remove(), 0); }; // Drop position when hovering a chip: before it (left half) or before the // next chip (right half). const chipBeforeId = (e: DragEvent, chipIndex: number): string | null => { const rect = e.currentTarget.getBoundingClientRect(); const onLeftHalf = e.clientX < rect.left + rect.width / 2; return onLeftHalf ? documentTabs[chipIndex].tabId : (documentTabs[chipIndex + 1]?.tabId ?? null); }; const performDrop = (e: DragEvent, beforeTabId: string | null) => { setDropBefore(undefined); const payload = readTabDrag(e.dataTransfer); if (payload) onMoveTab(payload.tileId, payload.tabId, beforeTabId); }; const marker = (
); return (
{ if (!isTabDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setDropBefore(null); // background = append (chips override below) }} onDragLeave={(e) => { // Clear the marker only when the drag actually leaves this strip — not // when moving onto one of its own chips (dragleave bubbles from them). if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setDropBefore(undefined); } }} onDrop={(e) => { if (!isTabDrag(e)) return; e.preventDefault(); performDrop(e, null); }} > {documentTabs.map((tab, i) => { const isActive = tab.tabId === activeTabId; const title = tabTitle(tab.slot); return (
{dropBefore === tab.tabId ? marker : null}
handleDragStart(e, tab.tabId)} onDragEnd={() => setDropBefore(undefined)} onDragOver={(e) => { if (!isTabDrag(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'move'; setDropBefore(chipBeforeId(e, i)); }} onDrop={(e) => { if (!isTabDrag(e)) return; e.preventDefault(); e.stopPropagation(); performDrop(e, chipBeforeId(e, i)); }} onMouseDown={(e) => handleAuxClose(e, tab.tabId)} onClick={() => onSelect(tab.tabId)} onContextMenu={(e) => handleContextMenu(e, tab.tabId)} className={`group flex shrink-0 cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs transition-none ${ isActive ? 'bg-accent/15 text-foreground' : 'text-foreground/60 hover:bg-border/30' }`} > {title}
); })} {dropBefore === null ? marker : null}
); }