From 6893923c73080ef226bc17efd013263ca9450a6e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 19 Apr 2026 17:22:30 -0700 Subject: [PATCH] feat: double-click tab to rename (inline edit) (#844) Co-authored-by: heyramzi --- .../src/components/tab-bar/SortableTab.tsx | 187 ++++++---- tests/e2e/tab-rename.spec.ts | 318 ++++++++++++++++++ tests/e2e/tabs.spec.ts | 1 + 3 files changed, 433 insertions(+), 73 deletions(-) create mode 100644 tests/e2e/tab-rename.spec.ts diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 912dd3f07cf..a1005164e6e 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -9,15 +9,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import type { TerminalTab } from '../../../../shared/types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' @@ -83,23 +74,48 @@ export default function SortableTab({ } const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) - const [renameOpen, setRenameOpen] = useState(false) + const [isEditing, setIsEditing] = useState(false) const [renameValue, setRenameValue] = useState('') const renameInputRef = useRef(null) + // Why: React's synthetic onBlur fires during the Input's unmount when isEditing flips + // to false. Without this guard, pressing Escape (or committing via Enter) would cause + // the blur handler to run commitRename a second time and overwrite the title with the + // uncommitted edits the user just discarded. This ref lets cancelRename/commitRename + // mark the rename as already resolved so the unmount-driven blur is a no-op. + const committedOrCancelledRef = useRef(false) const handleRenameOpen = useCallback(() => { + committedOrCancelledRef.current = false + // Why: snapshot the current title once on open. If the underlying tab.title + // changes mid-edit (e.g., a shell writes a new title via OSC escape), we + // intentionally do NOT refresh renameValue — the user's in-progress edit + // takes precedence so their keystrokes are never silently overwritten. setRenameValue(tab.customTitle ?? tab.title) - setRenameOpen(true) + setIsEditing(true) }, [tab.customTitle, tab.title]) - const handleRenameSubmit = useCallback(() => { + const commitRename = useCallback(() => { + if (committedOrCancelledRef.current) { + return + } + committedOrCancelledRef.current = true const trimmed = renameValue.trim() onSetCustomTitle(tab.id, trimmed.length > 0 ? trimmed : null) - setRenameOpen(false) + setIsEditing(false) }, [renameValue, onSetCustomTitle, tab.id]) + const cancelRename = useCallback(() => { + committedOrCancelledRef.current = true + setIsEditing(false) + }, []) + + // Why: rAF defers focus()+select() until after the Input mounts so the text + // is pre-selected (overwriting the old title is the common case). Deps are + // intentionally just [isEditing] — we do NOT re-run when tab.title or + // tab.customTitle change mid-edit, so external title updates cannot + // re-focus/re-select and disrupt the user's typing. useEffect(() => { - if (!renameOpen) { + if (!isEditing) { return } const frame = requestAnimationFrame(() => { @@ -107,7 +123,7 @@ export default function SortableTab({ renameInputRef.current?.select() }) return () => cancelAnimationFrame(frame) - }, [renameOpen]) + }, [isEditing]) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -115,6 +131,13 @@ export default function SortableTab({ return () => window.removeEventListener(CLOSE_ALL_CONTEXT_MENUS_EVENT, closeMenu) }, []) + // Why: while editing, suppress dnd-kit drag listeners and tab-activation/double-click + // handlers so typing/clicking inside the inline input doesn't start a drag, re-open the + // editor, or steal focus away from the input. We still spread `attributes` unconditionally + // so dnd-kit's a11y attributes (aria-roledescription, etc.) remain on the element — only + // the pointer listeners are gated so a drag can't start while typing. + const dragListeners = isEditing ? undefined : listeners + return ( <>
{ + if (isEditing) { + return + } + e.stopPropagation() + handleRenameOpen() + }} onPointerDown={(e) => { - if (e.button !== 0) { + if (isEditing || e.button !== 0) { return } onActivate(tab.id) - listeners?.onPointerDown?.(e) + dragListeners?.onPointerDown?.(e) }} onMouseDown={(e) => { // Why: prevent default browser middle-click behavior (auto-scroll) @@ -152,6 +184,9 @@ export default function SortableTab({ } }} onAuxClick={(e) => { + if (isEditing) { + return + } if (e.button === 1) { e.preventDefault() e.stopPropagation() @@ -162,14 +197,56 @@ export default function SortableTab({ - {tab.customTitle ?? tab.title} - {tab.color && ( + {isEditing ? ( + setRenameValue(event.target.value)} + onBlur={commitRename} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault() + commitRename() + } else if (event.key === 'Escape') { + event.preventDefault() + cancelRename() + } + }} + // Why: stop pointer/mouse events from bubbling to the outer div, which + // would otherwise trigger tab activation or start a dnd-kit drag while + // the user is trying to click inside the input. + onPointerDown={(event) => event.stopPropagation()} + onMouseDown={(event) => { + // Why: stop propagation so the outer tab's activation/drag handlers + // don't fire on clicks inside the input. Also preventDefault on middle + // click (button 1) to block Linux X11 primary-selection paste into the + // rename field, matching the outer tab's behavior. + event.stopPropagation() + if (event.button === 1) { + event.preventDefault() + } + }} + onClick={(event) => event.stopPropagation()} + onDoubleClick={(event) => event.stopPropagation()} + onAuxClick={(event) => event.stopPropagation()} + // Why: the base Input applies w-full min-w-0, which lets flex + // shrink it to ~0 when many tabs compete for horizontal space. + // Force a minimum width that matches the normal title box so the + // rename input stays usable even when the tab bar is saturated. + className="h-5 w-[130px] min-w-[130px] max-w-[130px] mr-1.5 px-1 py-0 text-xs" + spellCheck={false} + /> + ) : ( + {tab.customTitle ?? tab.title} + )} + {tab.color && !isEditing && ( )} - {isExpanded && ( + {isExpanded && !isEditing && ( )} - + {!isEditing && ( + + )}
@@ -272,44 +351,6 @@ export default function SortableTab({ - - - - Change Tab Title - - Leave empty to reset to the default title. - - -
{ - event.preventDefault() - handleRenameSubmit() - }} - > - setRenameValue(event.target.value)} - className="h-8 text-xs" - autoFocus - /> - - - - -
-
-
) } diff --git a/tests/e2e/tab-rename.spec.ts b/tests/e2e/tab-rename.spec.ts new file mode 100644 index 00000000000..a4259fd7bfd --- /dev/null +++ b/tests/e2e/tab-rename.spec.ts @@ -0,0 +1,318 @@ +/** + * E2E tests for inline tab renaming (double-click a tab to rename). + * + * User Prompt: + * - double-click a tab to rename it inline + */ + +import { test, expect } from './helpers/orca-app' +import { + waitForSessionReady, + waitForActiveWorktree, + getActiveWorktreeId, + getActiveTabId, + getWorktreeTabs, + ensureTerminalVisible +} from './helpers/store' + +test.describe('Tab Rename (Inline)', () => { + test.beforeEach(async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + // Why: clear any custom titles left by a previous test (the Electron app + // persists across tests in the worker) so tab locators key off the default + // title, not a stale rename like "My Custom Title". + await orcaPage.evaluate(() => { + const store = window.__store + if (!store) { + return + } + const state = store.getState() + for (const tabs of Object.values(state.tabsByWorktree)) { + for (const tab of tabs) { + if (tab.customTitle != null) { + state.setTabCustomTitle(tab.id, null) + } + } + } + }) + }) + + async function getActiveTabTitle( + page: Parameters[0], + worktreeId: string + ): Promise { + const activeId = await getActiveTabId(page) + expect(activeId).not.toBeNull() + const tabs = await getWorktreeTabs(page, worktreeId) + const tab = tabs.find((entry) => entry.id === activeId) + expect(tab).toBeDefined() + // Why: mirror what the UI renders (customTitle ?? title) so locators that + // key off the tab's visible text match what's actually on screen. + return tab!.title ?? '' + } + + function tabLocatorByTitle( + page: Parameters[0], + title: string + ): ReturnType[0]['locator']> { + // Why: backslash first so the backslashes we introduce when escaping the + // double-quote aren't themselves re-escaped; both chars are CSS-selector + // metacharacters inside a double-quoted attribute value. + const escaped = title.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + return page.locator(`[data-testid="sortable-tab"][data-tab-title="${escaped}"]`).first() + } + + async function getActiveCustomTitle( + page: Parameters[0], + worktreeId: string + ): Promise { + return page.evaluate((targetWorktreeId) => { + const store = window.__store + if (!store) { + return null + } + + const state = store.getState() + const activeId = state.activeTabIdByWorktree[targetWorktreeId] ?? state.activeTabId + const tab = (state.tabsByWorktree[targetWorktreeId] ?? []).find((t) => t.id === activeId) + return tab?.customTitle ?? null + }, worktreeId) + } + + test('double-clicking a tab opens an inline rename input and Enter commits', async ({ + orcaPage + }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const originalTitle = await getActiveTabTitle(orcaPage, worktreeId) + expect(originalTitle.length).toBeGreaterThan(0) + + const tabLocator = tabLocatorByTitle(orcaPage, originalTitle) + await tabLocator.dblclick() + + const renameInput = orcaPage.getByRole('textbox', { + name: `Rename tab ${originalTitle}`, + exact: true + }) + await expect(renameInput).toBeVisible() + + await renameInput.fill('My Custom Title') + await renameInput.press('Enter') + + await expect + .poll(async () => getActiveCustomTitle(orcaPage, worktreeId), { timeout: 3_000 }) + .toBe('My Custom Title') + await expect(renameInput).toBeHidden() + await expect(tabLocatorByTitle(orcaPage, 'My Custom Title')).toBeVisible() + }) + + test('Escape during inline rename discards the edit', async ({ orcaPage }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const originalTitle = await getActiveTabTitle(orcaPage, worktreeId) + + const tabLocator = tabLocatorByTitle(orcaPage, originalTitle) + await tabLocator.dblclick() + + const renameInput = orcaPage.getByRole('textbox', { + name: `Rename tab ${originalTitle}`, + exact: true + }) + await expect(renameInput).toBeVisible() + + await renameInput.fill('Should Be Discarded') + await renameInput.press('Escape') + + await expect(renameInput).toBeHidden() + await expect + .poll(async () => getActiveCustomTitle(orcaPage, worktreeId), { timeout: 3_000 }) + .toBe(null) + }) + + test('renaming to an empty string resets the tab to its default title', async ({ orcaPage }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + + // Why: seed a custom title directly via the store so this test asserts the + // "empty string → reset" behavior independently from the double-click flow. + await orcaPage.evaluate((targetWorktreeId) => { + const store = window.__store + if (!store) { + return + } + + const state = store.getState() + const activeId = state.activeTabIdByWorktree[targetWorktreeId] ?? state.activeTabId + if (activeId) { + state.setTabCustomTitle(activeId, 'Seeded Custom') + } + }, worktreeId) + + await expect + .poll(async () => getActiveCustomTitle(orcaPage, worktreeId), { timeout: 3_000 }) + .toBe('Seeded Custom') + + const tabLocator = tabLocatorByTitle(orcaPage, 'Seeded Custom') + await tabLocator.dblclick() + + const renameInput = orcaPage.getByRole('textbox', { + name: 'Rename tab Seeded Custom', + exact: true + }) + await expect(renameInput).toBeVisible() + + await renameInput.fill('') + await renameInput.press('Enter') + + await expect + .poll(async () => getActiveCustomTitle(orcaPage, worktreeId), { timeout: 3_000 }) + .toBe(null) + }) + + test('clicking away (blur) commits the rename', async ({ orcaPage }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + + // Why: need a second tab so we have something to click that isn't the + // rename input itself. Seed both with known titles so we can locate them. + await orcaPage.evaluate((targetWorktreeId) => { + const store = window.__store + if (!store) { + return + } + const state = store.getState() + const existing = state.tabsByWorktree[targetWorktreeId] ?? [] + if (existing.length < 2) { + state.createTab(targetWorktreeId) + } + }, worktreeId) + + await expect + .poll(async () => (await getWorktreeTabs(orcaPage, worktreeId)).length, { timeout: 3_000 }) + .toBeGreaterThanOrEqual(2) + + const tabs = await getWorktreeTabs(orcaPage, worktreeId) + const activeId = await getActiveTabId(orcaPage) + const activeTab = tabs.find((t) => t.id === activeId)! + const otherTab = tabs.find((t) => t.id !== activeId)! + + const tabLocator = tabLocatorByTitle(orcaPage, activeTab.title!) + await tabLocator.dblclick() + + const renameInput = orcaPage.getByRole('textbox', { + name: `Rename tab ${activeTab.title}`, + exact: true + }) + await expect(renameInput).toBeVisible() + + await renameInput.fill('Committed By Blur') + // Why: clicking the other tab triggers blur on the input, which should + // run commitRename and save the typed title before the focus shifts. + await tabLocatorByTitle(orcaPage, otherTab.title!).click() + + await expect(renameInput).toBeHidden() + await expect(tabLocatorByTitle(orcaPage, 'Committed By Blur')).toBeVisible() + expect( + await orcaPage.evaluate( + ({ targetWorktreeId, targetTabId }) => { + const store = window.__store + const state = store!.getState() + const tab = (state.tabsByWorktree[targetWorktreeId] ?? []).find( + (t) => t.id === targetTabId + ) + return tab?.customTitle ?? null + }, + { targetWorktreeId: worktreeId, targetTabId: activeTab.id } + ) + ).toBe('Committed By Blur') + }) + + test('right-clicking during inline rename commits and opens context menu', async ({ + orcaPage + }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const originalTitle = await getActiveTabTitle(orcaPage, worktreeId) + + const tabLocator = tabLocatorByTitle(orcaPage, originalTitle) + await tabLocator.dblclick() + + const renameInput = orcaPage.getByRole('textbox', { + name: `Rename tab ${originalTitle}`, + exact: true + }) + await expect(renameInput).toBeVisible() + + await renameInput.fill('Committed By Right Click') + // Why: right-clicking the tab blurs the input (commitRename runs) and + // opens the context menu. We assert the rename was saved; the menu + // assertion is intentionally light because the menu markup is shared + // with other specs. + await tabLocator.click({ button: 'right' }) + + await expect + .poll(async () => getActiveCustomTitle(orcaPage, worktreeId), { timeout: 3_000 }) + .toBe('Committed By Right Click') + await expect(renameInput).toBeHidden() + }) + + test('rename input stays at a usable width when many tabs are open', async ({ orcaPage }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + + // Why: create enough terminal tabs that flex space runs out. 15 is well + // above the threshold at which the pre-fix input collapsed, and it keeps + // the test fast. The width fix pins the input to 130px, so even saturated, + // it should stay near that size — we assert ≥100px to allow a bit of slack + // for fonts/padding/containers differing between environments. + await orcaPage.evaluate((targetWorktreeId) => { + const store = window.__store + if (!store) { + return + } + const state = store.getState() + const existing = (state.tabsByWorktree[targetWorktreeId] ?? []).length + for (let i = existing; i < 15; i++) { + state.createTab(targetWorktreeId) + } + }, worktreeId) + + await expect + .poll(async () => (await getWorktreeTabs(orcaPage, worktreeId)).length, { timeout: 5_000 }) + .toBeGreaterThanOrEqual(15) + + const activeTitle = await getActiveTabTitle(orcaPage, worktreeId) + const tabLocator = tabLocatorByTitle(orcaPage, activeTitle) + await tabLocator.dblclick() + + const renameInput = orcaPage.getByRole('textbox', { + name: `Rename tab ${activeTitle}`, + exact: true + }) + await expect(renameInput).toBeVisible() + + const box = await renameInput.boundingBox() + expect(box).not.toBeNull() + expect(box!.width).toBeGreaterThanOrEqual(100) + }) + + test('middle-clicking inside the rename input does not close the tab', async ({ orcaPage }) => { + const worktreeId = (await getActiveWorktreeId(orcaPage))! + const tabsBefore = (await getWorktreeTabs(orcaPage, worktreeId)).length + const originalTitle = await getActiveTabTitle(orcaPage, worktreeId) + + const tabLocator = tabLocatorByTitle(orcaPage, originalTitle) + await tabLocator.dblclick() + + const renameInput = orcaPage.getByRole('textbox', { + name: `Rename tab ${originalTitle}`, + exact: true + }) + await expect(renameInput).toBeVisible() + + // Why: the outer tab's middle-click handler closes the tab. The rename + // input stops propagation + preventDefaults middle-click so the tab + // isn't closed while the user is editing. + await renameInput.click({ button: 'middle' }) + + // The tab must still exist — no regression where editing-then-middle-click + // accidentally closes the tab out from under the input. + expect((await getWorktreeTabs(orcaPage, worktreeId)).length).toBe(tabsBefore) + }) +}) diff --git a/tests/e2e/tabs.spec.ts b/tests/e2e/tabs.spec.ts index 134e6ae89ca..9811df3a56a 100644 --- a/tests/e2e/tabs.spec.ts +++ b/tests/e2e/tabs.spec.ts @@ -5,6 +5,7 @@ * - New tab works * - dragging tabs around to reorder them * - closing tabs works + * - double-click a tab to rename it inline */ import { test, expect } from './helpers/orca-app'