Skip to content

Commit e2b2498

Browse files
Copilotsawka
andauthored
UI component for vertical tab bar
Dragging in the vertical tab bar had two UX artifacts: the drop marker reserved layout space (leaving a visible accent gap above selected tabs), and hover styling could remain on the old row after drop until the mouse moved. This updates drag visuals to be overlay-based and forces hover recalculation at drag end. - **Drop marker moved out of flow (no selected-tab accent gap)** - Replaced per-row in-flow divider placeholders with a single absolutely positioned drop line in the scroll container. - Drop line now aligns to actual tab boundaries (`offsetTop` / `offsetHeight`) so it covers the divider location directly. - **Drop target rendering simplified** - Container is `relative`; marker is conditionally rendered only while reordering. - `dropLineTop` is tracked during drag events and used to position the marker without affecting layout. - **Stale hover state cleared after drop** - Added a minimal drag-lifecycle reset mechanism (`hoverResetVersion`) and used it in `VTab` keys. - On drag end/drop, rows remount once, clearing browser-retained `:hover` on the old index immediately. ```tsx <VTab key={`${tab.id}:${hoverResetVersion}`} ... /> {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)" }} /> )} ``` - **<screenshot>** - https://github.com/user-attachments/assets/8c25ef6f-c600-484e-a4fa-6ac83657b484 <!-- START COPILOT CODING AGENT TIPS --> --- 🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. [Learn more about Advanced Security.](https://gh.io/cca-advanced-security) --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
1 parent b5d23e5 commit e2b2498

File tree

3 files changed

+403
-0
lines changed

3 files changed

+403
-0
lines changed

frontend/app/tab/vtab.tsx

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { makeIconClass } from "@/util/util";
5+
import { cn } from "@/util/util";
6+
import { useCallback, useEffect, useRef, useState } from "react";
7+
8+
const RenameFocusDelayMs = 50;
9+
10+
export interface VTabItem {
11+
id: string;
12+
name: string;
13+
indicator?: TabIndicator | null;
14+
}
15+
16+
interface VTabProps {
17+
tab: VTabItem;
18+
active: boolean;
19+
isDragging: boolean;
20+
isReordering: boolean;
21+
onSelect: () => void;
22+
onClose?: () => void;
23+
onRename?: (newName: string) => void;
24+
onDragStart: (event: React.DragEvent<HTMLDivElement>) => void;
25+
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
26+
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
27+
onDragEnd: () => void;
28+
}
29+
30+
export function VTab({
31+
tab,
32+
active,
33+
isDragging,
34+
isReordering,
35+
onSelect,
36+
onClose,
37+
onRename,
38+
onDragStart,
39+
onDragOver,
40+
onDrop,
41+
onDragEnd,
42+
}: VTabProps) {
43+
const [originalName, setOriginalName] = useState(tab.name);
44+
const [isEditable, setIsEditable] = useState(false);
45+
const editableRef = useRef<HTMLDivElement>(null);
46+
const editableTimeoutRef = useRef<NodeJS.Timeout | null>(null);
47+
48+
useEffect(() => {
49+
setOriginalName(tab.name);
50+
}, [tab.name]);
51+
52+
useEffect(() => {
53+
return () => {
54+
if (editableTimeoutRef.current) {
55+
clearTimeout(editableTimeoutRef.current);
56+
}
57+
};
58+
}, []);
59+
60+
const selectEditableText = useCallback(() => {
61+
if (!editableRef.current) {
62+
return;
63+
}
64+
editableRef.current.focus();
65+
const range = document.createRange();
66+
const selection = window.getSelection();
67+
if (!selection) {
68+
return;
69+
}
70+
range.selectNodeContents(editableRef.current);
71+
selection.removeAllRanges();
72+
selection.addRange(range);
73+
}, []);
74+
75+
const startRename = useCallback(() => {
76+
if (onRename == null || isReordering) {
77+
return;
78+
}
79+
if (editableTimeoutRef.current) {
80+
clearTimeout(editableTimeoutRef.current);
81+
}
82+
setIsEditable(true);
83+
editableTimeoutRef.current = setTimeout(() => {
84+
selectEditableText();
85+
}, RenameFocusDelayMs);
86+
}, [isReordering, onRename, selectEditableText]);
87+
88+
const handleBlur = () => {
89+
if (!editableRef.current) {
90+
return;
91+
}
92+
const newText = editableRef.current.textContent?.trim() || originalName;
93+
editableRef.current.textContent = newText;
94+
setIsEditable(false);
95+
if (newText !== originalName) {
96+
onRename?.(newText);
97+
}
98+
};
99+
100+
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (event) => {
101+
if (!editableRef.current) {
102+
return;
103+
}
104+
if (event.key === "Enter") {
105+
event.preventDefault();
106+
event.stopPropagation();
107+
editableRef.current.blur();
108+
return;
109+
}
110+
if (event.key !== "Escape") {
111+
return;
112+
}
113+
editableRef.current.textContent = originalName;
114+
editableRef.current.blur();
115+
event.preventDefault();
116+
event.stopPropagation();
117+
};
118+
119+
return (
120+
<div
121+
draggable
122+
onClick={onSelect}
123+
onDoubleClick={(event) => {
124+
event.stopPropagation();
125+
startRename();
126+
}}
127+
onDragStart={onDragStart}
128+
onDragOver={onDragOver}
129+
onDrop={onDrop}
130+
onDragEnd={onDragEnd}
131+
className={cn(
132+
"group relative flex h-9 w-full cursor-pointer items-center border-b border-border/70 pl-2 text-sm transition-colors select-none",
133+
"whitespace-nowrap",
134+
active
135+
? "bg-accent/20 text-primary"
136+
: isReordering
137+
? "bg-transparent text-secondary"
138+
: "bg-transparent text-secondary hover:bg-hover",
139+
isDragging && "opacity-50"
140+
)}
141+
>
142+
{tab.indicator && (
143+
<span className="mr-1 shrink-0 text-xs" style={{ color: tab.indicator.color || "#fbbf24" }}>
144+
<i className={makeIconClass(tab.indicator.icon, true, { defaultIcon: "bell" })} />
145+
</span>
146+
)}
147+
<div
148+
ref={editableRef}
149+
className={cn(
150+
"min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-[padding-right]",
151+
onClose && !isReordering && "group-hover:pr-[18px]",
152+
isEditable && "rounded-[2px] bg-white/15 outline-none"
153+
)}
154+
contentEditable={isEditable}
155+
role="textbox"
156+
aria-label="Tab name"
157+
aria-readonly={!isEditable}
158+
onBlur={handleBlur}
159+
onKeyDown={handleKeyDown}
160+
suppressContentEditableWarning={true}
161+
>
162+
{tab.name}
163+
</div>
164+
{onClose && (
165+
<button
166+
type="button"
167+
className={cn(
168+
"absolute top-1/2 right-0 shrink-0 -translate-y-1/2 cursor-pointer py-1 pl-1 pr-1.5 text-secondary transition",
169+
isReordering ? "opacity-0" : "opacity-0 group-hover:opacity-100 hover:text-primary"
170+
)}
171+
onClick={(event) => {
172+
event.stopPropagation();
173+
onClose();
174+
}}
175+
aria-label="Close tab"
176+
>
177+
<i className="fa fa-solid fa-xmark" />
178+
</button>
179+
)}
180+
</div>
181+
);
182+
}

frontend/app/tab/vtabbar.tsx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
// Copyright 2026, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { cn } from "@/util/util";
5+
import { useEffect, useMemo, useRef, useState } from "react";
6+
import { VTab, VTabItem } from "./vtab";
7+
export type { VTabItem } from "./vtab";
8+
9+
interface VTabBarProps {
10+
tabs: VTabItem[];
11+
activeTabId?: string;
12+
width?: number;
13+
className?: string;
14+
onSelectTab?: (tabId: string) => void;
15+
onCloseTab?: (tabId: string) => void;
16+
onRenameTab?: (tabId: string, newName: string) => void;
17+
onReorderTabs?: (tabIds: string[]) => void;
18+
}
19+
20+
function clampWidth(width?: number): number {
21+
if (width == null) {
22+
return 220;
23+
}
24+
if (width < 100) {
25+
return 100;
26+
}
27+
if (width > 400) {
28+
return 400;
29+
}
30+
return width;
31+
}
32+
33+
export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCloseTab, onRenameTab, onReorderTabs }: VTabBarProps) {
34+
const [orderedTabs, setOrderedTabs] = useState<VTabItem[]>(tabs);
35+
const [dragTabId, setDragTabId] = useState<string | null>(null);
36+
const [dropIndex, setDropIndex] = useState<number | null>(null);
37+
const [dropLineTop, setDropLineTop] = useState<number | null>(null);
38+
const [hoverResetVersion, setHoverResetVersion] = useState(0);
39+
const dragSourceRef = useRef<string | null>(null);
40+
const didResetHoverForDragRef = useRef(false);
41+
42+
useEffect(() => {
43+
setOrderedTabs(tabs);
44+
}, [tabs]);
45+
46+
const barWidth = useMemo(() => clampWidth(width), [width]);
47+
48+
const clearDragState = () => {
49+
if (dragSourceRef.current != null && !didResetHoverForDragRef.current) {
50+
didResetHoverForDragRef.current = true;
51+
setHoverResetVersion((version) => version + 1);
52+
}
53+
dragSourceRef.current = null;
54+
setDragTabId(null);
55+
setDropIndex(null);
56+
setDropLineTop(null);
57+
};
58+
59+
const reorder = (targetIndex: number) => {
60+
const sourceTabId = dragSourceRef.current;
61+
if (sourceTabId == null) {
62+
return;
63+
}
64+
const sourceIndex = orderedTabs.findIndex((tab) => tab.id === sourceTabId);
65+
if (sourceIndex === -1) {
66+
return;
67+
}
68+
const boundedTargetIndex = Math.max(0, Math.min(targetIndex, orderedTabs.length));
69+
const adjustedTargetIndex = sourceIndex < boundedTargetIndex ? boundedTargetIndex - 1 : boundedTargetIndex;
70+
if (sourceIndex === adjustedTargetIndex) {
71+
return;
72+
}
73+
const nextTabs = [...orderedTabs];
74+
const [movedTab] = nextTabs.splice(sourceIndex, 1);
75+
nextTabs.splice(adjustedTargetIndex, 0, movedTab);
76+
setOrderedTabs(nextTabs);
77+
onReorderTabs?.(nextTabs.map((tab) => tab.id));
78+
};
79+
80+
return (
81+
<div
82+
className={cn("flex h-full min-w-[100px] max-w-[400px] flex-col overflow-hidden border-r border-border bg-panel", className)}
83+
style={{ width: barWidth }}
84+
>
85+
<div
86+
className="relative flex min-h-0 flex-1 flex-col overflow-y-auto"
87+
onDragOver={(event) => {
88+
event.preventDefault();
89+
if (event.target === event.currentTarget) {
90+
setDropIndex(orderedTabs.length);
91+
setDropLineTop(event.currentTarget.scrollHeight);
92+
}
93+
}}
94+
onDrop={(event) => {
95+
event.preventDefault();
96+
if (dropIndex != null) {
97+
reorder(dropIndex);
98+
}
99+
clearDragState();
100+
}}
101+
>
102+
{orderedTabs.map((tab, index) => (
103+
<VTab
104+
key={`${tab.id}:${hoverResetVersion}`}
105+
tab={tab}
106+
active={tab.id === activeTabId}
107+
isDragging={dragTabId === tab.id}
108+
isReordering={dragTabId != null}
109+
onSelect={() => onSelectTab?.(tab.id)}
110+
onClose={onCloseTab ? () => onCloseTab(tab.id) : undefined}
111+
onRename={onRenameTab ? (newName) => onRenameTab(tab.id, newName) : undefined}
112+
onDragStart={(event) => {
113+
didResetHoverForDragRef.current = false;
114+
dragSourceRef.current = tab.id;
115+
event.dataTransfer.effectAllowed = "move";
116+
event.dataTransfer.setData("text/plain", tab.id);
117+
setDragTabId(tab.id);
118+
setDropIndex(index);
119+
setDropLineTop(event.currentTarget.offsetTop);
120+
}}
121+
onDragOver={(event) => {
122+
event.preventDefault();
123+
const rect = event.currentTarget.getBoundingClientRect();
124+
const relativeY = event.clientY - rect.top;
125+
const midpoint = event.currentTarget.offsetHeight / 2;
126+
const insertBefore = relativeY < midpoint;
127+
setDropIndex(insertBefore ? index : index + 1);
128+
setDropLineTop(
129+
insertBefore
130+
? event.currentTarget.offsetTop
131+
: event.currentTarget.offsetTop + event.currentTarget.offsetHeight
132+
);
133+
}}
134+
onDrop={(event) => {
135+
event.preventDefault();
136+
if (dropIndex != null) {
137+
reorder(dropIndex);
138+
}
139+
clearDragState();
140+
}}
141+
onDragEnd={clearDragState}
142+
/>
143+
))}
144+
{dragTabId != null && dropIndex != null && dropLineTop != null && (
145+
<div
146+
className="pointer-events-none absolute left-0 right-0 border-t-2 border-accent/80"
147+
style={{ top: dropLineTop, transform: "translateY(-1px)" }}
148+
/>
149+
)}
150+
</div>
151+
</div>
152+
);
153+
}

0 commit comments

Comments
 (0)