tui: preserve close target after tab removal (#44720)

This commit is contained in:
Simon Klee
2026-08-24 19:04:50 +02:00
committed by GitHub
parent aa8c1f6dac
commit e2a7600a2a
2 changed files with 248 additions and 12 deletions
+140 -12
View File
@@ -48,6 +48,16 @@ const MARQUEE_INTERVAL = 80
const CONTEXT_MENU_WIDTH = 16
const MIDDLE_MOUSE_BUTTON = 1
const RIGHT_MOUSE_BUTTON = 2
const MOUSE_CLOSE_HOLD_MS = 5_000
type MouseCloseHold = {
items: string[]
ids: string[]
widths: number[]
closed: string
target: string
x: number
}
type TabContextMenuState = {
x: number
@@ -101,6 +111,45 @@ function fadeTitleColor(color: RGBA, background: RGBA, index: number, length: nu
return opacity === 0 ? color : tint(color, background, opacity)
}
function heldSessionTabLayout(hold: MouseCloseHold, tabs: readonly SessionTab[]) {
const ids = tabs.map((tab) => tab.sessionID)
const expected = hold.items.filter((id) => id !== hold.closed)
const unchanged = ids.length === hold.items.length && ids.every((id, index) => id === hold.items[index])
const removed = ids.length === expected.length && ids.every((id, index) => id === expected[index])
if (!unchanged && !removed) return undefined
const visibleIDs = hold.ids.filter((id) => ids.includes(id))
if (removed && !visibleIDs.includes(hold.target)) {
visibleIDs.push(hold.target)
visibleIDs.sort((a, b) => ids.indexOf(a) - ids.indexOf(b))
}
const positions = visibleIDs.map((id) => ids.indexOf(id))
const start = positions[0]
if (start === undefined || positions.some((position, index) => position !== start + index)) return undefined
const visible = visibleIDs.flatMap((id) => tabs.find((tab) => tab.sessionID === id) ?? [])
if (visible.length !== visibleIDs.length) return undefined
const widths = visibleIDs.map((id) => hold.widths[hold.ids.indexOf(id)] ?? 1)
if (removed) {
const index = visibleIDs.indexOf(hold.target)
if (index === -1) return undefined
const leading = start > 0 ? sessionTabOverflowWidth(start) : 0
const preceding = widths.slice(0, index).reduce((sum, width) => sum + width, 0)
// The close glyph sits one cell in from the right edge: x = tab start + width - 2.
const width = hold.x - leading - preceding + 2
if (width < 1) return undefined
widths[index] = width
}
return {
tabs: visible,
widths,
before: start,
after: tabs.length - start - visible.length,
start,
total: widths.reduce((sum, width) => sum + width, 0),
}
}
export function createMarquee(animations: () => boolean) {
const [offset, setOffset] = createSignal(0)
const [active, setActive] = createSignal<string>()
@@ -858,9 +907,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// so the strip never flashes the pre-drag order while the write is in flight.
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
const [contextMenu, setContextMenu] = createSignal<TabContextMenuState>()
let strip: { screenX: number; screenY: number } | undefined
const [closeHold, setCloseHold] = createSignal<MouseCloseHold>()
let strip: { screenX: number; screenY: number; width: number; height: number } | undefined
let didDrag = false
let addPressed = false
let closeHoldTimer: ReturnType<typeof setTimeout> | undefined
let releasingCloseHold = false
const clearCloseHold = () => {
setCloseHold(undefined)
if (closeHoldTimer) clearTimeout(closeHoldTimer)
closeHoldTimer = undefined
}
const releaseCloseHold = () => {
if (!closeHold()) return
releasingCloseHold = true
clearCloseHold()
}
onCleanup(clearCloseHold)
// A captured drag ends with a synthetic up on its drop target; do not turn that into a click.
let suppressClick = false
const hueStep = () => (mode() === "light" ? 800 : 200)
@@ -884,14 +947,23 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const index = tabs.tabs().findIndex((tab) => tab.sessionID === pending.sessionID)
if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined)
})
const layout = createMemo((previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
adaptiveSessionTabLayout(
items(),
activeID(),
dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0),
previous?.start,
),
const heldLayout = createMemo(() => {
const hold = closeHold()
return hold ? heldSessionTabLayout(hold, items()) : undefined
})
const layout = createMemo(
(previous: ReturnType<typeof adaptiveSessionTabLayout> | undefined) =>
heldLayout() ??
adaptiveSessionTabLayout(
items(),
activeID(),
dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0),
previous?.start,
),
)
createEffect(() => {
if (closeHold() && !heldLayout()) clearCloseHold()
})
createEffect(() => {
const active = marquee.active()
if (active && !layout().tabs.some((tab) => tab.sessionID === active)) marquee.reset()
@@ -927,6 +999,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
)
let signature = ""
let total = 0
let terminalWidth = dimensions().width
// createComputed runs before render effects, so seeded widths are visible on the first frame
// of a membership change instead of flashing the final layout.
@@ -935,12 +1008,29 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const nextSignature = identity()
const changed = Boolean(signature) && signature !== nextSignature
const resized = Boolean(total) && total !== layout().total
const terminalResized = terminalWidth !== dimensions().width
const previous = signature
signature = nextSignature
total = layout().total
terminalWidth = dimensions().width
const releasing = releasingCloseHold
releasingCloseHold = false
if (terminalResized && closeHold()) {
clearCloseHold()
return
}
if (closeHold() && heldLayout()) {
const current = untrack(motion.value)
const seeded = changed
? seedSessionTabMotion(previous.split(":"), layout().tabs.map((tab) => tab.sessionID), current, next)
: current
if (!seeded) return motion.jump(next)
motion.jump({ ...seeded, widths: next.widths })
return motion.animate(next)
}
if (!changed && !resized) return motion.animate(next)
// Identity-stable total changes are terminal resizes and still jump.
if (!changed) return motion.jump(next)
if (!changed) return releasing ? motion.animate(next) : motion.jump(next)
const seeded = seedSessionTabMotion(
previous.split(":"),
layout().tabs.map((tab) => tab.sessionID),
@@ -959,12 +1049,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const active = activeIndex()
const remainder = layout().total - widths.reduce((sum, width) => sum + width, 0)
// Absorb only rounding slack; membership animations leave a real gap while widths grow into place.
if (active !== -1 && Math.abs(remainder) <= layout().tabs.length) widths[active]! += remainder
if (active !== -1 && Math.abs(remainder) <= layout().tabs.length) widths[active] += remainder
return new Map(
layout().tabs.map((tab, index) => [
tab.sessionID,
{
width: widths[index]!,
width: widths[index],
selection: current.selections[index] ?? Number(tab.sessionID === activeID()),
activity: current.activities[index] ?? Number(statuses().get(tab.sessionID)!.complete),
},
@@ -972,6 +1062,29 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
)
})
const holdCloseCell = (sessionID: string, x: number) => {
if (!strip) return clearCloseHold()
const current = layout()
const index = current.tabs.findIndex((tab) => tab.sessionID === sessionID)
const all = items()
const itemIndex = all.findIndex((tab) => tab.sessionID === sessionID)
const target = all[itemIndex + 1] ?? all[itemIndex - 1]
if (index === -1 || itemIndex === -1 || !target) return clearCloseHold()
const ids = current.tabs.map((tab) => tab.sessionID)
const values = ids.map((id) => visuals().get(id))
if (values.some((value) => !value)) return clearCloseHold()
setCloseHold({
items: all.map((tab) => tab.sessionID),
ids,
widths: values.map((value) => value!.width),
closed: sessionID,
target: target.sessionID,
x: x - strip.screenX,
})
if (closeHoldTimer) clearTimeout(closeHoldTimer)
closeHoldTimer = setTimeout(releaseCloseHold, MOUSE_CLOSE_HOLD_MS)
}
// Map an absolute pointer column to the items index of the visible slot beneath it.
const slotAt = (x: number) => {
if (!strip) return undefined
@@ -1015,7 +1128,17 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
position="relative"
flexDirection="row"
zIndex={1}
onMouseOut={marquee.leaveHovered}
onMouseOut={(event) => {
marquee.leaveHovered()
if (!strip) return
if (
event.x < strip.screenX ||
event.x >= strip.screenX + strip.width ||
event.y < strip.screenY ||
event.y >= strip.screenY + strip.height
)
releaseCloseHold()
}}
onMouseUp={(event) => {
if (event.button === RIGHT_MOUSE_BUTTON) return
release()
@@ -1127,6 +1250,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOut={() => marquee.leave(tab.sessionID)}
onMouseDown={(event) => {
if (event.button === MIDDLE_MOUSE_BUTTON) {
releaseCloseHold()
didDrag = false
setDragging(undefined)
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
@@ -1135,6 +1259,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
return
}
if (event.button === RIGHT_MOUSE_BUTTON) {
releaseCloseHold()
didDrag = false
setDragging(undefined)
setContextMenu({
@@ -1148,6 +1273,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
return
}
didDrag = false
releaseCloseHold()
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
@@ -1205,6 +1331,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
// land here first, and must select the tab instead of closing it invisibly.
if (hovered() !== tab.sessionID) return
event.stopPropagation()
holdCloseCell(tab.sessionID, event.x)
tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID)
}}
>
@@ -1229,6 +1356,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseDown={(event) => {
releaseCloseHold()
didDrag = false
setDragging(undefined)
addPressed = event.button !== RIGHT_MOUSE_BUTTON
@@ -99,3 +99,111 @@ test("middle-click closes a session tab without selecting it", async () => {
app.renderer.destroy()
}
})
test("keeps consecutive close controls fixed across overflow window changes", async () => {
const [active, setActive] = createSignal("fifth")
const [items, setItems] = createSignal([
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
{ sessionID: "third", title: "Third" },
{ sessionID: "fourth", title: "Fourth" },
{ sessionID: "fifth", title: "Fifth" },
])
const closed: string[] = []
const controller = {
tabs: items,
current: active,
select: setActive,
close: (sessionID?: string) => {
if (!sessionID) return
const current = items()
closed.push(sessionID)
setActive("first")
setItems(current.filter((tab) => tab.sessionID !== sessionID))
},
move() {},
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 46, height: 2 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Third"))
await app.mockMouse.moveTo(11, 0)
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[11] === "✕")
await app.mockMouse.click(11, 0)
await app.waitForFrame((frame) => items().length === 4 && Array.from(frame.split("\n")[0] ?? "")[11] === "✕")
await app.mockMouse.click(11, 0)
expect(closed).toEqual(["third", "fourth"])
} finally {
app.renderer.destroy()
}
})
test("reflows held tabs when the pointer leaves the strip", async () => {
const [active, setActive] = createSignal("first")
const [items, setItems] = createSignal([
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
{ sessionID: "third", title: "Third" },
{ sessionID: "fourth", title: "Fourth" },
])
const controller = {
tabs: items,
current: active,
select: setActive,
close: (sessionID?: string) => {
if (!sessionID) return
const current = items()
const index = current.findIndex((tab) => tab.sessionID === sessionID)
setActive((current[index + 1] ?? current[index - 1])?.sessionID)
setItems(current.filter((tab) => tab.sessionID !== sessionID))
},
move() {},
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<box flexDirection="column">
<SessionTabs controller={controller} animations={false} />
<text>outside</text>
</box>
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 60, height: 2 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Fourth"))
await app.mockMouse.moveTo(22, 0)
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[22] === "✕")
await app.mockMouse.click(22, 0)
await app.waitForFrame((frame) => items().length === 3 && Array.from(frame.split("\n")[0] ?? "")[22] === "✕")
await app.mockMouse.moveTo(0, 1)
await app.mockMouse.moveTo(20, 0)
await app.waitForFrame((frame) => Array.from(frame.split("\n")[0] ?? "")[20] === "✕")
expect(Array.from(app.captureCharFrame().split("\n")[0] ?? "")[22]).not.toBe("✕")
} finally {
app.renderer.destroy()
}
})