diff --git a/frontend/app/tab/vtab.tsx b/frontend/app/tab/vtab.tsx new file mode 100644 index 0000000000..f3eee3bf76 --- /dev/null +++ b/frontend/app/tab/vtab.tsx @@ -0,0 +1,182 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { makeIconClass } from "@/util/util"; +import { cn } from "@/util/util"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const RenameFocusDelayMs = 50; + +export interface VTabItem { + id: string; + name: string; + indicator?: TabIndicator | null; +} + +interface VTabProps { + tab: VTabItem; + active: boolean; + isDragging: boolean; + isReordering: boolean; + onSelect: () => void; + onClose?: () => void; + onRename?: (newName: string) => void; + onDragStart: (event: React.DragEvent) => void; + onDragOver: (event: React.DragEvent) => void; + onDrop: (event: React.DragEvent) => void; + onDragEnd: () => void; +} + +export function VTab({ + tab, + active, + isDragging, + isReordering, + onSelect, + onClose, + onRename, + onDragStart, + onDragOver, + onDrop, + onDragEnd, +}: VTabProps) { + const [originalName, setOriginalName] = useState(tab.name); + const [isEditable, setIsEditable] = useState(false); + const editableRef = useRef(null); + const editableTimeoutRef = useRef(null); + + useEffect(() => { + setOriginalName(tab.name); + }, [tab.name]); + + useEffect(() => { + return () => { + if (editableTimeoutRef.current) { + clearTimeout(editableTimeoutRef.current); + } + }; + }, []); + + const selectEditableText = useCallback(() => { + if (!editableRef.current) { + return; + } + editableRef.current.focus(); + const range = document.createRange(); + const selection = window.getSelection(); + if (!selection) { + return; + } + range.selectNodeContents(editableRef.current); + selection.removeAllRanges(); + selection.addRange(range); + }, []); + + const startRename = useCallback(() => { + if (onRename == null || isReordering) { + return; + } + if (editableTimeoutRef.current) { + clearTimeout(editableTimeoutRef.current); + } + setIsEditable(true); + editableTimeoutRef.current = setTimeout(() => { + selectEditableText(); + }, RenameFocusDelayMs); + }, [isReordering, onRename, selectEditableText]); + + const handleBlur = () => { + if (!editableRef.current) { + return; + } + const newText = editableRef.current.textContent?.trim() || originalName; + editableRef.current.textContent = newText; + setIsEditable(false); + if (newText !== originalName) { + onRename?.(newText); + } + }; + + const handleKeyDown: React.KeyboardEventHandler = (event) => { + if (!editableRef.current) { + return; + } + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + editableRef.current.blur(); + return; + } + if (event.key !== "Escape") { + return; + } + editableRef.current.textContent = originalName; + editableRef.current.blur(); + event.preventDefault(); + event.stopPropagation(); + }; + + return ( +
{ + event.stopPropagation(); + startRename(); + }} + onDragStart={onDragStart} + onDragOver={onDragOver} + onDrop={onDrop} + onDragEnd={onDragEnd} + className={cn( + "group relative flex h-9 w-full cursor-pointer items-center border-b border-border/70 pl-2 text-sm transition-colors select-none", + "whitespace-nowrap", + active + ? "bg-accent/20 text-primary" + : isReordering + ? "bg-transparent text-secondary" + : "bg-transparent text-secondary hover:bg-hover", + isDragging && "opacity-50" + )} + > + {tab.indicator && ( + + + + )} +
+ {tab.name} +
+ {onClose && ( + + )} +
+ ); +} diff --git a/frontend/app/tab/vtabbar.tsx b/frontend/app/tab/vtabbar.tsx new file mode 100644 index 0000000000..ad558373dd --- /dev/null +++ b/frontend/app/tab/vtabbar.tsx @@ -0,0 +1,153 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { cn } from "@/util/util"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { VTab, VTabItem } from "./vtab"; +export type { VTabItem } from "./vtab"; + +interface VTabBarProps { + tabs: VTabItem[]; + activeTabId?: string; + width?: number; + className?: string; + onSelectTab?: (tabId: string) => void; + onCloseTab?: (tabId: string) => void; + onRenameTab?: (tabId: string, newName: string) => void; + onReorderTabs?: (tabIds: string[]) => void; +} + +function clampWidth(width?: number): number { + if (width == null) { + return 220; + } + if (width < 100) { + return 100; + } + if (width > 400) { + return 400; + } + return width; +} + +export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCloseTab, onRenameTab, onReorderTabs }: VTabBarProps) { + const [orderedTabs, setOrderedTabs] = useState(tabs); + const [dragTabId, setDragTabId] = useState(null); + const [dropIndex, setDropIndex] = useState(null); + const [dropLineTop, setDropLineTop] = useState(null); + const [hoverResetVersion, setHoverResetVersion] = useState(0); + const dragSourceRef = useRef(null); + const didResetHoverForDragRef = useRef(false); + + useEffect(() => { + setOrderedTabs(tabs); + }, [tabs]); + + const barWidth = useMemo(() => clampWidth(width), [width]); + + const clearDragState = () => { + if (dragSourceRef.current != null && !didResetHoverForDragRef.current) { + didResetHoverForDragRef.current = true; + setHoverResetVersion((version) => version + 1); + } + dragSourceRef.current = null; + setDragTabId(null); + setDropIndex(null); + setDropLineTop(null); + }; + + const reorder = (targetIndex: number) => { + const sourceTabId = dragSourceRef.current; + if (sourceTabId == null) { + return; + } + const sourceIndex = orderedTabs.findIndex((tab) => tab.id === sourceTabId); + if (sourceIndex === -1) { + return; + } + const boundedTargetIndex = Math.max(0, Math.min(targetIndex, orderedTabs.length)); + const adjustedTargetIndex = sourceIndex < boundedTargetIndex ? boundedTargetIndex - 1 : boundedTargetIndex; + if (sourceIndex === adjustedTargetIndex) { + return; + } + const nextTabs = [...orderedTabs]; + const [movedTab] = nextTabs.splice(sourceIndex, 1); + nextTabs.splice(adjustedTargetIndex, 0, movedTab); + setOrderedTabs(nextTabs); + onReorderTabs?.(nextTabs.map((tab) => tab.id)); + }; + + return ( +
+
{ + event.preventDefault(); + if (event.target === event.currentTarget) { + setDropIndex(orderedTabs.length); + setDropLineTop(event.currentTarget.scrollHeight); + } + }} + onDrop={(event) => { + event.preventDefault(); + if (dropIndex != null) { + reorder(dropIndex); + } + clearDragState(); + }} + > + {orderedTabs.map((tab, index) => ( + onSelectTab?.(tab.id)} + onClose={onCloseTab ? () => onCloseTab(tab.id) : undefined} + onRename={onRenameTab ? (newName) => onRenameTab(tab.id, newName) : undefined} + onDragStart={(event) => { + didResetHoverForDragRef.current = false; + dragSourceRef.current = tab.id; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", tab.id); + setDragTabId(tab.id); + setDropIndex(index); + setDropLineTop(event.currentTarget.offsetTop); + }} + onDragOver={(event) => { + event.preventDefault(); + const rect = event.currentTarget.getBoundingClientRect(); + const relativeY = event.clientY - rect.top; + const midpoint = event.currentTarget.offsetHeight / 2; + const insertBefore = relativeY < midpoint; + setDropIndex(insertBefore ? index : index + 1); + setDropLineTop( + insertBefore + ? event.currentTarget.offsetTop + : event.currentTarget.offsetTop + event.currentTarget.offsetHeight + ); + }} + onDrop={(event) => { + event.preventDefault(); + if (dropIndex != null) { + reorder(dropIndex); + } + clearDragState(); + }} + onDragEnd={clearDragState} + /> + ))} + {dragTabId != null && dropIndex != null && dropLineTop != null && ( +
+ )} +
+
+ ); +} diff --git a/frontend/preview/previews/vtabbar.preview.tsx b/frontend/preview/previews/vtabbar.preview.tsx new file mode 100644 index 0000000000..b576786749 --- /dev/null +++ b/frontend/preview/previews/vtabbar.preview.tsx @@ -0,0 +1,68 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { VTabBar, VTabItem } from "@/app/tab/vtabbar"; +import { useState } from "react"; + +const InitialTabs: VTabItem[] = [ + { id: "vtab-1", name: "Terminal" }, + { id: "vtab-2", name: "Build Logs", indicator: { icon: "bell", color: "#f59e0b" } }, + { id: "vtab-3", name: "Deploy" }, + { id: "vtab-4", name: "Wave AI" }, + { id: "vtab-5", name: "A Very Long Tab Name To Show Truncation" }, +]; + +export function VTabBarPreview() { + const [tabs, setTabs] = useState(InitialTabs); + const [activeTabId, setActiveTabId] = useState(InitialTabs[0].id); + const [width, setWidth] = useState(220); + + const handleCloseTab = (tabId: string) => { + setTabs((prevTabs) => { + const nextTabs = prevTabs.filter((tab) => tab.id !== tabId); + if (activeTabId === tabId && nextTabs.length > 0) { + setActiveTabId(nextTabs[0].id); + } + return nextTabs; + }); + }; + + return ( +
+
+
Width: {width}px
+ setWidth(Number(event.target.value))} + className="w-full cursor-pointer" + /> +

+ Drag tabs to reorder. Names, indicators, and close buttons remain single-line. +

+
+
+ { + setTabs((prevTabs) => + prevTabs.map((tab) => (tab.id === tabId ? { ...tab, name: newName } : tab)) + ); + }} + onReorderTabs={(tabIds) => { + setTabs((prevTabs) => { + const tabById = new Map(prevTabs.map((tab) => [tab.id, tab])); + return tabIds.map((tabId) => tabById.get(tabId)).filter((tab) => tab != null); + }); + }} + /> +
+
+ ); +}