Skip to content
182 changes: 182 additions & 0 deletions frontend/app/tab/vtab.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>) => void;
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
onDrop: (event: React.DragEvent<HTMLDivElement>) => 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<HTMLDivElement>(null);
const editableTimeoutRef = useRef<NodeJS.Timeout | null>(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<HTMLDivElement> = (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 (
<div
draggable
onClick={onSelect}
onDoubleClick={(event) => {
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 && (
<span className="mr-1 shrink-0 text-xs" style={{ color: tab.indicator.color || "#fbbf24" }}>
<i className={makeIconClass(tab.indicator.icon, true, { defaultIcon: "bell" })} />
</span>
)}
<div
ref={editableRef}
className={cn(
"min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-[padding-right]",
onClose && !isReordering && "group-hover:pr-[18px]",
isEditable && "rounded-[2px] bg-white/15 outline-none"
)}
contentEditable={isEditable}
role="textbox"
aria-label="Tab name"
aria-readonly={!isEditable}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
suppressContentEditableWarning={true}
>
{tab.name}
</div>
{onClose && (
<button
type="button"
className={cn(
"absolute top-1/2 right-0 shrink-0 -translate-y-1/2 cursor-pointer py-1 pl-1 pr-1.5 text-secondary transition",
isReordering ? "opacity-0" : "opacity-0 group-hover:opacity-100 hover:text-primary"
)}
onClick={(event) => {
event.stopPropagation();
onClose();
}}
aria-label="Close tab"
>
<i className="fa fa-solid fa-xmark" />
</button>
)}
</div>
);
}
153 changes: 153 additions & 0 deletions frontend/app/tab/vtabbar.tsx
Original file line number Diff line number Diff line change
@@ -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<VTabItem[]>(tabs);
const [dragTabId, setDragTabId] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [dropLineTop, setDropLineTop] = useState<number | null>(null);
const [hoverResetVersion, setHoverResetVersion] = useState(0);
const dragSourceRef = useRef<string | null>(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 (
<div
className={cn("flex h-full min-w-[100px] max-w-[400px] flex-col overflow-hidden border-r border-border bg-panel", className)}
style={{ width: barWidth }}
>
<div
className="relative flex min-h-0 flex-1 flex-col overflow-y-auto"
onDragOver={(event) => {
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) => (
<VTab
key={`${tab.id}:${hoverResetVersion}`}
tab={tab}
active={tab.id === activeTabId}
isDragging={dragTabId === tab.id}
isReordering={dragTabId != null}
onSelect={() => 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 && (
<div
className="pointer-events-none absolute left-0 right-0 border-t-2 border-accent/80"
style={{ top: dropLineTop, transform: "translateY(-1px)" }}
/>
)}
</div>
</div>
);
}
Loading