diff --git a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx index 6e1c49ddc94..c37597e2932 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-surface.tsx @@ -163,6 +163,8 @@ export function renderTabBarSurface({ .join(' ')} > {renderedItems} + {/* Why: a short end inset keeps a last tab's close control off the chevron and fade. */} +
diff --git a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts new file mode 100644 index 00000000000..28745773ad6 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.test.ts @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { + computeTabStripEndInset, + computeTabStripScrollLeft, + findLastTabStripTab, + isLastTabStripTab, + syncTabStripEndPad, + TAB_STRIP_REVEAL_INSET_PX +} from './tab-strip-active-tab-scroll' + +function mockBox( + el: HTMLElement, + box: { + offsetLeft?: number + offsetWidth?: number + clientWidth?: number + scrollWidth?: number + } +): void { + Object.defineProperties(el, { + offsetLeft: { configurable: true, value: box.offsetLeft ?? 0 }, + offsetWidth: { configurable: true, value: box.offsetWidth ?? 0 }, + clientWidth: { configurable: true, value: box.clientWidth ?? 0 }, + scrollWidth: { configurable: true, value: box.scrollWidth ?? 0 } + }) +} + +describe('computeTabStripEndInset', () => { + it('is zero when tabs already fit', () => { + expect( + computeTabStripEndInset({ + stripClientWidth: 400, + lastTabWidth: 180, + contentWidth: 360 + }) + ).toBe(0) + }) + + it('uses the fade-sized inset when the last tab overflows', () => { + expect( + computeTabStripEndInset({ + stripClientWidth: 400, + lastTabWidth: 180, + contentWidth: 900 + }) + ).toBe(TAB_STRIP_REVEAL_INSET_PX) + }) + + it('shrinks the inset when the last tab almost fills the strip', () => { + expect( + computeTabStripEndInset({ + stripClientWidth: 200, + lastTabWidth: 190, + contentWidth: 400 + }) + ).toBe(10) + }) + + it('is zero when the last tab is at least as wide as the strip', () => { + expect( + computeTabStripEndInset({ + stripClientWidth: 180, + lastTabWidth: 180, + contentWidth: 400 + }) + ).toBe(0) + }) +}) + +describe('computeTabStripScrollLeft', () => { + it('centers a last tab when trailing pad makes that scroll reachable', () => { + expect( + computeTabStripScrollLeft({ + stripScrollWidth: 1010, + stripClientWidth: 400, + stripScrollLeft: 0, + tabOffsetLeft: 720, + tabWidth: 180, + inline: 'center' + }) + ).toBe(610) + }) + + it('clamps a last-tab center to the end when there is no trailing pad', () => { + expect( + computeTabStripScrollLeft({ + stripScrollWidth: 900, + stripClientWidth: 400, + stripScrollLeft: 0, + tabOffsetLeft: 720, + tabWidth: 180, + inline: 'center' + }) + ).toBe(500) + }) + + it('keeps a fully visible middle tab in place', () => { + expect( + computeTabStripScrollLeft({ + stripScrollWidth: 900, + stripClientWidth: 400, + stripScrollLeft: 200, + tabOffsetLeft: 280, + tabWidth: 120, + inline: 'nearest' + }) + ).toBe(200) + }) + + it('scrolls far enough that a clipped trailing tab clears the fade inset', () => { + expect( + computeTabStripScrollLeft({ + stripScrollWidth: 900, + stripClientWidth: 400, + stripScrollLeft: 0, + tabOffsetLeft: 320, + tabWidth: 180, + inline: 'nearest' + }) + ).toBe(320 + 180 - 400 + TAB_STRIP_REVEAL_INSET_PX) + }) +}) + +describe('tab strip last-tab helpers', () => { + it('treats the last data-tab-id node as the last tab and ignores the end pad', () => { + const strip = document.createElement('div') + const first = document.createElement('div') + first.setAttribute('data-tab-id', 'one') + const last = document.createElement('div') + last.setAttribute('data-tab-id', 'two') + const pad = document.createElement('div') + pad.setAttribute('data-tab-strip-end-pad', '') + strip.append(first, last, pad) + + expect(findLastTabStripTab(strip)).toBe(last) + expect(isLastTabStripTab(strip, last)).toBe(true) + expect(isLastTabStripTab(strip, first)).toBe(false) + }) + + it('sizes the end pad to the fade inset when the last tab overflows', () => { + const strip = document.createElement('div') + const last = document.createElement('div') + last.setAttribute('data-tab-id', 'end') + const pad = document.createElement('div') + pad.setAttribute('data-tab-strip-end-pad', '') + strip.append(last, pad) + mockBox(strip, { clientWidth: 400 }) + mockBox(last, { offsetLeft: 720, offsetWidth: 180 }) + + expect(syncTabStripEndPad(strip)).toBe(TAB_STRIP_REVEAL_INSET_PX) + expect(pad.style.width).toBe(`${TAB_STRIP_REVEAL_INSET_PX}px`) + }) + + it('clears the end pad when every tab already fits', () => { + const strip = document.createElement('div') + const last = document.createElement('div') + last.setAttribute('data-tab-id', 'end') + const pad = document.createElement('div') + pad.setAttribute('data-tab-strip-end-pad', '') + pad.style.width = '110px' + strip.append(last, pad) + mockBox(strip, { clientWidth: 400 }) + mockBox(last, { offsetLeft: 0, offsetWidth: 180 }) + + expect(syncTabStripEndPad(strip)).toBe(0) + expect(pad.style.width).toBe('0px') + }) +}) diff --git a/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts new file mode 100644 index 00000000000..e19012ff962 --- /dev/null +++ b/src/renderer/src/components/tab-bar/tab-strip-active-tab-scroll.ts @@ -0,0 +1,100 @@ +export const TAB_STRIP_TAB_ID_SELECTOR = '[data-tab-id]' +export const TAB_STRIP_END_PAD_ATTR = 'data-tab-strip-end-pad' + +// Why: matches the 1.5rem fade mask so nearest-reveal keeps the close control out of the fade. +export const TAB_STRIP_REVEAL_INSET_PX = 24 + +export function findLastTabStripTab(strip: ParentNode): HTMLElement | null { + const tabs = strip.querySelectorAll(TAB_STRIP_TAB_ID_SELECTOR) + return [...tabs].at(-1) ?? null +} + +export function isLastTabStripTab(strip: ParentNode, tab: Element): boolean { + return findLastTabStripTab(strip) === tab +} + +export function computeTabStripEndInset({ + stripClientWidth, + lastTabWidth, + contentWidth +}: { + stripClientWidth: number + lastTabWidth: number + contentWidth: number +}): number { + if (stripClientWidth <= 0 || lastTabWidth <= 0 || contentWidth <= stripClientWidth) { + return 0 + } + // Why: a half-viewport pad centered the last tab but hid earlier tabs and + // created a drop dead-zone; a fade-sized inset keeps the close control clear. + return Math.min(TAB_STRIP_REVEAL_INSET_PX, Math.max(0, stripClientWidth - lastTabWidth)) +} + +export function computeTabStripScrollLeft({ + stripScrollWidth, + stripClientWidth, + stripScrollLeft, + tabOffsetLeft, + tabWidth, + inline +}: { + stripScrollWidth: number + stripClientWidth: number + stripScrollLeft: number + tabOffsetLeft: number + tabWidth: number + inline: 'nearest' | 'center' +}): number { + const maxScrollLeft = Math.max(0, stripScrollWidth - stripClientWidth) + if (inline === 'center') { + return clamp(tabOffsetLeft + tabWidth / 2 - stripClientWidth / 2, 0, maxScrollLeft) + } + + const tabStart = tabOffsetLeft + const tabEnd = tabOffsetLeft + tabWidth + const viewStart = stripScrollLeft + TAB_STRIP_REVEAL_INSET_PX + const viewEnd = stripScrollLeft + stripClientWidth - TAB_STRIP_REVEAL_INSET_PX + if (tabStart >= viewStart && tabEnd <= viewEnd) { + return stripScrollLeft + } + if (tabEnd > viewEnd) { + return clamp(tabEnd - stripClientWidth + TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft) + } + return clamp(tabStart - TAB_STRIP_REVEAL_INSET_PX, 0, maxScrollLeft) +} + +export function syncTabStripEndPad(strip: HTMLElement): number { + const lastTab = findLastTabStripTab(strip) + const pad = computeTabStripEndInset({ + stripClientWidth: strip.clientWidth, + lastTabWidth: lastTab?.offsetWidth ?? 0, + contentWidth: lastTab ? lastTab.offsetLeft + lastTab.offsetWidth : 0 + }) + const spacer = strip.querySelector(`[${TAB_STRIP_END_PAD_ATTR}]`) + if (spacer) { + const nextWidth = `${pad}px` + if (spacer.style.width !== nextWidth) { + spacer.style.width = nextWidth + } + } + return pad +} + +export function scrollTabStripTabIntoView( + strip: HTMLElement, + tab: HTMLElement, + inline: 'nearest' | 'center' +): void { + strip.scrollLeft = computeTabStripScrollLeft({ + stripScrollWidth: strip.scrollWidth, + stripClientWidth: strip.clientWidth, + stripScrollLeft: strip.scrollLeft, + tabOffsetLeft: tab.offsetLeft, + tabWidth: tab.offsetWidth, + inline + }) +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} diff --git a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts index 77af550b748..682b8434d04 100644 --- a/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts +++ b/src/renderer/src/components/tab-bar/tab-strip-overflow-navigation.ts @@ -6,6 +6,11 @@ import { type TabStripScrollMetrics } from './tab-strip-scroll-metrics' import { isTabStripPointerGestureActive } from './tab-strip-pointer-gesture' +import { + findLastTabStripTab, + scrollTabStripTabIntoView, + syncTabStripEndPad +} from './tab-strip-active-tab-scroll' const TAB_STRIP_SCROLL_FRACTION = 0.75 const TAB_STRIP_MIN_SCROLL_STEP_PX = 120 @@ -51,6 +56,8 @@ export function useTabStripOverflowNavigation({ const tabStripRef = useRef(null) const prevStripLenRef = useRef<{ worktreeId: string; len: number } | null>(null) const stickToEndRef = useRef(false) + const activeVisibleTabIdRef = useRef(activeVisibleTabId) + activeVisibleTabIdRef.current = activeVisibleTabId const [tabStripOverflowState, setTabStripOverflowState] = useState( EMPTY_TAB_STRIP_OVERFLOW_STATE ) @@ -109,6 +116,7 @@ export function useTabStripOverflowNavigation({ onScroll() const handleStripResize = (): void => { + syncTabStripEndPad(el) updateTabStripOverflowState() // If the user is pinned to the right edge, keep it pinned even as tab // labels (e.g. "Terminal 5" -> branch name) expand and change scrollWidth. @@ -138,6 +146,7 @@ export function useTabStripOverflowNavigation({ } if (!prev || prev.worktreeId !== worktreeId) { prevStripLenRef.current = { worktreeId, len: tabCount } + syncTabStripEndPad(strip) updateTabStripOverflowState() return } @@ -148,6 +157,7 @@ export function useTabStripOverflowNavigation({ if (!el) { return } + syncTabStripEndPad(el) el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) updateTabStripOverflowState() } @@ -155,17 +165,28 @@ export function useTabStripOverflowNavigation({ requestAnimationFrame(scrollToEnd) } if (tabCount > prev.len && !pointerGestureActive) { - const scrollToEnd = (): void => { + const revealNewEndTab = (): void => { const el = tabStripRef.current if (!el) { return } - el.scrollLeft = Math.max(0, el.scrollWidth - el.clientWidth) - stickToEndRef.current = true + syncTabStripEndPad(el) + const lastTab = findLastTabStripTab(el) + const activeId = activeVisibleTabIdRef.current + const activeTab = + activeId == null + ? null + : el.querySelector(`[data-tab-id="${CSS.escape(activeId)}"]`) + // Why: only a newly opened last tab should pin to the end; a restored + // mid-strip tab must not yank the viewport to the last tab. + if (lastTab && (activeTab == null || lastTab === activeTab)) { + scrollTabStripTabIntoView(el, lastTab, 'center') + stickToEndRef.current = true + } updateTabStripOverflowState() } - scrollToEnd() - requestAnimationFrame(scrollToEnd) + revealNewEndTab() + requestAnimationFrame(revealNewEndTab) } prevStripLenRef.current = { worktreeId, len: tabCount } updateTabStripOverflowState() @@ -189,7 +210,8 @@ export function useTabStripOverflowNavigation({ requestAnimationFrame(updateTabStripOverflowState) return } - activeTab.scrollIntoView({ block: 'nearest', inline: 'nearest' }) + syncTabStripEndPad(strip) + scrollTabStripTabIntoView(strip, activeTab, 'nearest') requestAnimationFrame(updateTabStripOverflowState) }, [activeVisibleTabId, updateTabStripOverflowState])