Skip to content

Commit 6704e53

Browse files
Copilotsawka
andcommitted
fix: tighten vertical tab spacing and add inline rename
Co-authored-by: sawka <2722291+sawka@users.noreply.github.com>
1 parent a708ad8 commit 6704e53

3 files changed

Lines changed: 111 additions & 10 deletions

File tree

frontend/app/tab/vtab.tsx

Lines changed: 102 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
import { makeIconClass } from "@/util/util";
55
import { cn } from "@/util/util";
6+
import { useCallback, useEffect, useRef, useState } from "react";
7+
8+
const RenameFocusDelayMs = 50;
69

710
export interface VTabItem {
811
id: string;
@@ -17,6 +20,7 @@ interface VTabProps {
1720
isReordering: boolean;
1821
onSelect: () => void;
1922
onClose?: () => void;
23+
onRename?: (newName: string) => void;
2024
onDragStart: (event: React.DragEvent<HTMLDivElement>) => void;
2125
onDragOver: (event: React.DragEvent<HTMLDivElement>) => void;
2226
onDrop: (event: React.DragEvent<HTMLDivElement>) => void;
@@ -30,27 +34,108 @@ export function VTab({
3034
isReordering,
3135
onSelect,
3236
onClose,
37+
onRename,
3338
onDragStart,
3439
onDragOver,
3540
onDrop,
3641
onDragEnd,
3742
}: 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+
38119
return (
39120
<div
40121
draggable
41122
onClick={onSelect}
123+
onDoubleClick={(event) => {
124+
event.stopPropagation();
125+
startRename();
126+
}}
42127
onDragStart={onDragStart}
43128
onDragOver={onDragOver}
44129
onDrop={onDrop}
45130
onDragEnd={onDragEnd}
46131
className={cn(
47-
"group relative flex h-9 w-full cursor-pointer items-center rounded-md border pl-2 pr-1 text-sm transition-colors select-none",
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",
48133
"whitespace-nowrap",
49134
active
50-
? "border-accent/40 bg-accent/20 text-primary"
135+
? "bg-accent/20 text-primary"
51136
: isReordering
52-
? "border-transparent bg-transparent text-secondary"
53-
: "border-transparent bg-transparent text-secondary hover:border-border hover:bg-hover",
137+
? "bg-transparent text-secondary"
138+
: "bg-transparent text-secondary hover:bg-hover",
54139
isDragging && "opacity-50"
55140
)}
56141
>
@@ -59,19 +144,28 @@ export function VTab({
59144
<i className={makeIconClass(tab.indicator.icon, true, { defaultIcon: "bell" })} />
60145
</span>
61146
)}
62-
<span
147+
<div
148+
ref={editableRef}
63149
className={cn(
64150
"min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-[padding-right]",
65-
onClose && !isReordering && "group-hover:pr-6"
151+
onClose && !isReordering && "group-hover:pr-[18px]",
152+
isEditable && "rounded-[2px] bg-white/15 outline-none"
66153
)}
154+
contentEditable={isEditable}
155+
role="textbox"
156+
aria-label="Tab name"
157+
aria-readonly={!isEditable}
158+
onBlur={handleBlur}
159+
onKeyDown={handleKeyDown}
160+
suppressContentEditableWarning={true}
67161
>
68162
{tab.name}
69-
</span>
163+
</div>
70164
{onClose && (
71165
<button
72166
type="button"
73167
className={cn(
74-
"absolute top-1/2 right-0 shrink-0 -translate-y-1/2 cursor-pointer p-1 text-secondary transition",
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",
75169
isReordering ? "opacity-0" : "opacity-0 group-hover:opacity-100 hover:text-primary"
76170
)}
77171
onClick={(event) => {

frontend/app/tab/vtabbar.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface VTabBarProps {
1313
className?: string;
1414
onSelectTab?: (tabId: string) => void;
1515
onCloseTab?: (tabId: string) => void;
16+
onRenameTab?: (tabId: string, newName: string) => void;
1617
onReorderTabs?: (tabIds: string[]) => void;
1718
}
1819

@@ -29,7 +30,7 @@ function clampWidth(width?: number): number {
2930
return width;
3031
}
3132

32-
export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCloseTab, onReorderTabs }: VTabBarProps) {
33+
export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCloseTab, onRenameTab, onReorderTabs }: VTabBarProps) {
3334
const [orderedTabs, setOrderedTabs] = useState<VTabItem[]>(tabs);
3435
const [dragTabId, setDragTabId] = useState<string | null>(null);
3536
const [dropIndex, setDropIndex] = useState<number | null>(null);
@@ -81,7 +82,7 @@ export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCl
8182
style={{ width: barWidth }}
8283
>
8384
<div
84-
className="flex min-h-0 flex-1 flex-col overflow-y-auto p-1"
85+
className="flex min-h-0 flex-1 flex-col overflow-y-auto"
8586
onDragOver={(event) => {
8687
event.preventDefault();
8788
if (event.target === event.currentTarget) {
@@ -106,6 +107,7 @@ export function VTabBar({ tabs, activeTabId, width, className, onSelectTab, onCl
106107
isReordering={dragTabId != null}
107108
onSelect={() => onSelectTab?.(tab.id)}
108109
onClose={onCloseTab ? () => onCloseTab(tab.id) : undefined}
110+
onRename={onRenameTab ? (newName) => onRenameTab(tab.id, newName) : undefined}
109111
onDragStart={(event) => {
110112
dragSourceRef.current = tab.id;
111113
event.dataTransfer.effectAllowed = "move";

frontend/preview/previews/vtabbar.preview.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ export function VTabBarPreview() {
5050
width={width}
5151
onSelectTab={setActiveTabId}
5252
onCloseTab={handleCloseTab}
53+
onRenameTab={(tabId, newName) => {
54+
setTabs((prevTabs) =>
55+
prevTabs.map((tab) => (tab.id === tabId ? { ...tab, name: newName } : tab))
56+
);
57+
}}
5358
onReorderTabs={(tabIds) => {
5459
setTabs((prevTabs) => {
5560
const tabById = new Map(prevTabs.map((tab) => [tab.id, tab]));

0 commit comments

Comments
 (0)