tui: preserve close target after tab removal (#44720)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user