mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat: double-click tab to rename (inline edit) (#844)
Co-authored-by: heyramzi <ramzi@upsys-consulting.com>
This commit is contained in:
parent
08b0a3058a
commit
6893923c73
3 changed files with 433 additions and 73 deletions
|
|
@ -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<HTMLInputElement>(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 (
|
||||
<>
|
||||
<div
|
||||
|
|
@ -128,19 +151,28 @@ export default function SortableTab({
|
|||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-testid="sortable-tab"
|
||||
data-tab-title={tab.customTitle ?? tab.title}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
{...dragListeners}
|
||||
className={`group relative flex items-center h-full px-3 text-sm cursor-pointer select-none shrink-0 border-r border-border ${
|
||||
isActive
|
||||
? 'bg-accent/40 text-foreground border-b-transparent'
|
||||
: 'bg-card text-muted-foreground hover:text-foreground hover:bg-accent/50'
|
||||
}`}
|
||||
onDoubleClick={(e) => {
|
||||
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({
|
|||
<TerminalIcon
|
||||
className={`w-3.5 h-3.5 mr-1.5 shrink-0 ${isActive ? 'text-foreground' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="truncate max-w-[130px] mr-1.5">{tab.customTitle ?? tab.title}</span>
|
||||
{tab.color && (
|
||||
{isEditing ? (
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
aria-label={`Rename tab ${tab.customTitle ?? tab.title}`}
|
||||
onChange={(event) => 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}
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate max-w-[130px] mr-1.5">{tab.customTitle ?? tab.title}</span>
|
||||
)}
|
||||
{tab.color && !isEditing && (
|
||||
<span
|
||||
className="mr-1.5 size-2 rounded-full shrink-0"
|
||||
style={{ backgroundColor: tab.color }}
|
||||
/>
|
||||
)}
|
||||
{isExpanded && (
|
||||
{isExpanded && !isEditing && (
|
||||
<button
|
||||
className={`mr-1 flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
|
|
@ -187,20 +264,22 @@ export default function SortableTab({
|
|||
<Minimize2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose(tab.id)
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
{!isEditing && (
|
||||
<button
|
||||
className={`flex items-center justify-center w-4 h-4 rounded-sm shrink-0 ${
|
||||
isActive
|
||||
? 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
: 'text-transparent group-hover:text-muted-foreground hover:!text-foreground hover:!bg-muted'
|
||||
}`}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose(tab.id)
|
||||
}}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -272,44 +351,6 @@ export default function SortableTab({
|
|||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Dialog open={renameOpen} onOpenChange={setRenameOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">Change Tab Title</DialogTitle>
|
||||
<DialogDescription className="text-xs">
|
||||
Leave empty to reset to the default title.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
className="space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
handleRenameSubmit()
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
ref={renameInputRef}
|
||||
value={renameValue}
|
||||
onChange={(event) => setRenameValue(event.target.value)}
|
||||
className="h-8 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRenameOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" size="sm">
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
318
tests/e2e/tab-rename.spec.ts
Normal file
318
tests/e2e/tab-rename.spec.ts
Normal file
|
|
@ -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<typeof getActiveTabId>[0],
|
||||
worktreeId: string
|
||||
): Promise<string> {
|
||||
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<typeof getActiveTabId>[0],
|
||||
title: string
|
||||
): ReturnType<Parameters<typeof getActiveTabId>[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<typeof getActiveTabId>[0],
|
||||
worktreeId: string
|
||||
): Promise<string | null> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue