feat(app): improve titlebar tab layout (#33914)

This commit is contained in:
Brendan Allan 2026-06-25 22:39:59 +08:00 committed by GitHub
commit c097d51135
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 219 additions and 213 deletions

View file

@ -22,7 +22,7 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) {
for (const slot of slots) { for (const slot of slots) {
const id = slot.dataset.tabKey const id = slot.dataset.tabKey
if (!id) continue if (!id) continue
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]") const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) continue if (!tab) continue
tabWidthById.set(id, tab.getBoundingClientRect().width) tabWidthById.set(id, tab.getBoundingClientRect().width)
} }
@ -33,7 +33,7 @@ export function captureTabDragLayout(list: HTMLElement, order: string[]) {
const secondId = order[1] const secondId = order[1]
for (const slot of slots) { for (const slot of slots) {
if (slot.dataset.tabKey !== secondId) continue if (slot.dataset.tabKey !== secondId) continue
const tab = slot.querySelector<HTMLElement>("[data-titlebar-tab]") const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector<HTMLElement>("[data-titlebar-tab]")
if (!tab) break if (!tab) break
const style = getComputedStyle(slot) const style = getComputedStyle(slot)
dividerWidth = dividerWidth =

View file

@ -1,20 +1,16 @@
[data-titlebar-tab] [data-slot="tab-close"] { [data-titlebar-tab] [data-slot="tab-close"] {
position: absolute; position: absolute;
top: 0; top: 4px;
right: 0; right: 4px;
display: flex; display: flex;
height: 28px; height: 20px;
width: 28px; width: 20px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
[data-slot="titlebar-tabs"] {
container-type: inline-size;
}
[data-titlebar-tab-list] { [data-titlebar-tab-list] {
gap: calc(min(12px, 2cqw) + 1.5px); gap: 13.5px;
} }
[data-titlebar-tab-slot] { [data-titlebar-tab-slot] {
@ -25,7 +21,7 @@
content: ""; content: "";
position: absolute; position: absolute;
top: 8px; top: 8px;
left: calc(0px - min(6px, 1cqw) - 0.75px); left: -6.75px;
width: 1.5px; width: 1.5px;
height: 12px; height: 12px;
border-radius: 9999px; border-radius: 9999px;
@ -54,19 +50,11 @@
right: 100%; right: 100%;
} }
[data-titlebar-tab] [data-slot="tab-link"] {
padding-right: 22px;
}
[data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"])
[data-slot="tab-link"] { [data-slot="tab-link"] {
padding-right: 0; padding-right: 0;
} }
[data-titlebar-tab][data-editing="true"] [data-slot="tab-link"] {
padding-right: 22px;
}
[data-titlebar-tab] [data-slot="tab-title"] { [data-titlebar-tab] [data-slot="tab-title"] {
min-width: 0; min-width: 0;
} }
@ -95,4 +83,10 @@
[data-titlebar-tab-title] { [data-titlebar-tab-title] {
display: none; display: none;
} }
[data-slot="tab-close"] {
right: auto;
left: 50%;
transform: translateX(-50%);
}
} }

View file

@ -29,6 +29,9 @@ export function TabNavItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const language = useLanguage() const language = useLanguage()
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
@ -170,105 +173,112 @@ export function TabNavItem(props: {
return ( return (
<div <div
ref={(el) => { data-titlebar-tab-slot
tabRoot = el data-tab-key={props.tabKey}
forwardTabRef(props.ref, el) class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
}} classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
data-titlebar-tab onPointerDown={props.onPointerDown}
data-slot="titlebar-tab-item"
data-title-overflow={titleOverflowing()}
data-editing={editing()}
class="group relative flex h-7 w-full min-w-0 max-w-56 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
classList={{ invisible: props.hidden }}
data-active={props.active}
data-dragging={props.dragging}
data-pressed={props.pressed}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
> >
<Show when={props.session()}> <div
{(session) => { ref={(el) => {
return ( tabRoot = el
<a forwardTabRef(props.ref, el)
data-slot="tab-link" }}
data-titlebar-tab-link data-titlebar-tab
href={props.href} data-slot="titlebar-tab-item"
draggable={false} data-title-overflow={titleOverflowing()}
onDragStart={(event) => { data-editing={editing()}
event.preventDefault() class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
event.stopPropagation() data-active={props.active}
}} data-dragging={props.dragging}
onClick={(event) => { data-pressed={props.pressed}
event.preventDefault() onMouseDown={(event) => {
if (editing()) return if (event.button !== 1) return
if (props.suppressNavigation?.()) return closeTab(event)
props.onNavigate() }}
}} >
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" <Show when={props.session()}>
> {(session) => {
<span data-slot="project-avatar-slot"> return (
<SessionTabAvatar <a
project={project()} data-slot="tab-link"
directory={session().directory} data-titlebar-tab-link
sessionId={session().id} href={props.href}
activeServer={props.activeServer} draggable={false}
/> onDragStart={(event) => {
</span>
<span
ref={(el) => {
titleEl = el
titleEl.textContent = session().title
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault() event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation() event.stopPropagation()
}} }}
onClick={(event) => { onClick={(event) => {
if (!editing()) return
event.preventDefault() event.preventDefault()
if (editing()) return
if (props.suppressNavigation?.()) return
props.onNavigate()
}} }}
/> class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]"
</a> >
) <span data-slot="project-avatar-slot">
}} <SessionTabAvatar
</Show> project={project()}
directory={session().directory}
<div data-slot="tab-close"> sessionId={session().id}
<IconButtonV2 activeServer={props.activeServer}
size="small" />
variant="ghost-muted" </span>
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" <span
onPointerDown={(event) => { ref={(el) => {
event.preventDefault() titleEl = el
event.stopPropagation() titleEl.textContent = session().title
}}
data-slot="tab-title"
data-titlebar-tab-title
class="min-w-0 flex-1 outline-none leading-4"
classList={{
"overflow-hidden text-clip whitespace-nowrap": !editing(),
"select-text": editing(),
}}
contenteditable={editing() ? true : undefined}
onDblClick={openRename}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void closeRename(true)
return
}
if (event.key !== "Escape") return
event.preventDefault()
titleEl.textContent = session().title
void closeRename(false)
}}
onBlur={() => void closeRename(true)}
onPointerDown={(event) => {
if (!editing()) return
event.stopPropagation()
}}
onClick={(event) => {
if (!editing()) return
event.preventDefault()
}}
/>
</a>
)
}} }}
onClick={closeTab} </Show>
icon={<IconV2 name="xmark-small" />}
/> <div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
/>
</div>
</div> </div>
</div> </div>
) )
@ -285,6 +295,9 @@ export function DraftTabItem(props: {
dragging?: boolean dragging?: boolean
pressed?: boolean pressed?: boolean
hidden?: boolean hidden?: boolean
tabKey: string
dragActive: boolean
onPointerDown: (event: PointerEvent) => void
}) { }) {
const closeTab = (event: MouseEvent) => { const closeTab = (event: MouseEvent) => {
event.preventDefault() event.preventDefault()
@ -293,58 +306,69 @@ export function DraftTabItem(props: {
} }
return ( return (
<div <div
ref={(el) => forwardTabRef(props.ref, el)} data-titlebar-tab-slot
data-titlebar-tab data-tab-key={props.tabKey}
data-slot="titlebar-tab-item" class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
data-active={props.active} classList={{ invisible: props.hidden, "pointer-events-none": props.dragActive }}
data-dragging={props.dragging} onPointerDown={props.onPointerDown}
data-pressed={props.pressed}
class="group relative flex h-7 w-full min-w-0 max-w-56 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] pl-1.5 pr-8 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true'][data-pressed='true']:[--tab-bg:var(--v2-overlay-simple-overlay-pressed)]"
classList={{ invisible: props.hidden }}
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}}
> >
<a <div
data-slot="tab-link" ref={(el) => forwardTabRef(props.ref, el)}
data-titlebar-tab-link data-titlebar-tab
href={props.href} data-slot="titlebar-tab-item"
draggable={false} data-active={props.active}
onDragStart={(event) => { data-dragging={props.dragging}
event.preventDefault() data-pressed={props.pressed}
event.stopPropagation() class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]"
onMouseDown={(event) => {
if (event.button !== 1) return
closeTab(event)
}} }}
onClick={(event) => {
event.preventDefault()
if (props.suppressNavigation?.()) return
props.onNavigate()
}}
class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 overflow-hidden text-[13px] font-medium leading-5 text-v2-text-text-faint group-data-[active='true']:text-[var(--v2-text-text-base)]"
> >
<span class="flex size-4 shrink-0 items-center justify-center"> <a
<IconV2 name="edit" /> data-slot="tab-link"
</span> data-titlebar-tab-link
<span data-titlebar-tab-title class="truncate leading-5"> href={props.href}
{props.title} draggable={false}
</span> onDragStart={(event) => {
</a>
<div data-slot="tab-close" class="absolute right-0 inset-y-0 flex w-7 items-center justify-center">
<IconButtonV2
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
}} }}
onMouseDown={(event) => { onClick={(event) => {
event.preventDefault() event.preventDefault()
event.stopPropagation() if (props.suppressNavigation?.()) return
props.onNavigate()
}} }}
onClick={closeTab} class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]"
icon={<IconV2 name="xmark-small" />} >
aria-label="Close tab" <span class="flex size-4 shrink-0 items-center justify-center">
/> <IconV2 name="edit" />
</span>
<span
data-titlebar-tab-title
class="min-w-0 flex-1 overflow-hidden text-clip whitespace-nowrap outline-none leading-4"
>
{props.title}
</span>
</a>
<div data-slot="tab-close" class="group-hover:bg-[var(--tab-bg)] group-data-[active=true]:bg-[var(--tab-bg)]">
<IconButtonV2
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100"
onClick={closeTab}
icon={<IconV2 name="xmark-small" />}
aria-label="Close tab"
/>
</div>
</div> </div>
</div> </div>
) )

View file

@ -100,41 +100,33 @@ function SessionTabSlot(props: {
}) })
return ( return (
<div <TabNavItem
data-titlebar-tab-slot tabKey={props.id}
data-tab-key={props.id} dragActive={props.dragActive}
class="flex min-w-0 max-w-56 flex-1 basis-0"
classList={{
hidden: !session(),
"pointer-events-none": props.dragActive,
}}
onPointerDown={props.onPointerDown} onPointerDown={props.onPointerDown}
> ref={ref}
<TabNavItem href={tabHref(props.tab)}
ref={ref} server={props.tab.server}
href={tabHref(props.tab)} session={session}
server={props.tab.server} onTitleChange={(title) => {
session={session} const value = session()
onTitleChange={(title) => { const ctx = props.serverCtx()
const value = session() if (value && ctx) ctx.sync.session.remember({ ...value, title })
const ctx = props.serverCtx() }}
if (value && ctx) ctx.sync.session.remember({ ...value, title }) onTitleChangeFailed={(title) => {
}} const value = session()
onTitleChangeFailed={(title) => { const ctx = props.serverCtx()
const value = session() if (value && ctx) ctx.sync.session.remember({ ...value, title })
const ctx = props.serverCtx() }}
if (value && ctx) ctx.sync.session.remember({ ...value, title }) onNavigate={() => props.onNavigate(ref)}
}} onClose={props.onClose}
onNavigate={() => props.onNavigate(ref)} active={props.active()}
onClose={props.onClose} activeServer={props.tab.server === props.activeServerKey}
active={props.active()} forceTruncate={props.forceTruncate}
activeServer={props.tab.server === props.activeServerKey} suppressNavigation={props.suppressNavigation}
forceTruncate={props.forceTruncate} pressed={props.pressed()}
suppressNavigation={props.suppressNavigation} hidden={props.dragged() || !session()}
pressed={props.pressed()} />
hidden={props.dragged()}
/>
</div>
) )
} }
@ -336,7 +328,10 @@ export function TitlebarTabStrip(props: {
if (event.button !== 0 || drag.active) return if (event.button !== 0 || drag.active) return
if (!canStartTabDrag(event.pointerType)) return if (!canStartTabDrag(event.pointerType)) return
if (isTabCloseTarget(event.target)) return if (isTabCloseTarget(event.target)) return
const tabEl = (event.currentTarget as HTMLElement).querySelector<HTMLDivElement>("[data-titlebar-tab]") const target = event.currentTarget as HTMLDivElement
const tabEl = target.matches("[data-titlebar-tab]")
? target
: target.querySelector<HTMLDivElement>("[data-titlebar-tab]")
if (!tabEl) return if (!tabEl) return
if (!tabEl.querySelector('[data-slot="tab-link"]')) return if (!tabEl.querySelector('[data-slot="tab-link"]')) return
const tab = props.tabs.find((item) => tabKey(item) === id) const tab = props.tabs.find((item) => tabKey(item) === id)
@ -459,13 +454,13 @@ export function TitlebarTabStrip(props: {
return ( return (
<> <>
<div data-slot="titlebar-tabs" class="relative min-w-0 flex-1"> <div data-slot="titlebar-tabs" class="relative min-w-0">
<div <div
data-slot="titlebar-tabs-scroll" data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]" class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef} ref={scrollRef}
> >
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}> <div data-titlebar-tab-list class="flex min-w-0 flex-row items-center" ref={listRef}>
<For each={displayTabs()}> <For each={displayTabs()}>
{(tab, index) => { {(tab, index) => {
const id = tabKey(tab) const id = tabKey(tab)
@ -503,30 +498,23 @@ export function TitlebarTabStrip(props: {
} }
return ( return (
<div <DraftTabItem
data-titlebar-tab-slot tabKey={id}
data-tab-key={id} dragActive={drag.active}
class="flex min-w-0 max-w-56 flex-1 basis-0"
classList={{
"pointer-events-none": drag.active,
}}
onPointerDown={(event) => { onPointerDown={(event) => {
if (dragged()) return if (dragged()) return
onPointerDown(id, event) onPointerDown(id, event)
}} }}
> ref={ref}
<DraftTabItem href={tabHref(tab)}
ref={ref} title={language.t("command.session.new")}
href={tabHref(tab)} onNavigate={() => props.onNavigate(tab, ref)}
title={language.t("command.session.new")} onClose={() => props.onClose(tab)}
onNavigate={() => props.onNavigate(tab, ref)} suppressNavigation={() => suppressNavigation()}
onClose={() => props.onClose(tab)} active={props.currentTab() === tab}
suppressNavigation={() => suppressNavigation()} pressed={pressedId() === id}
active={props.currentTab() === tab} hidden={dragged()}
pressed={pressedId() === id} />
hidden={dragged()}
/>
</div>
) )
}} }}
</For> </For>

View file

@ -481,6 +481,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
/> />
</TooltipV2> </TooltipV2>
</Show> </Show>
<div class="flex-1" />
<TitlebarV2Right state={v2RightState()} /> <TitlebarV2Right state={v2RightState()} />
<Show when={windows() && !electronWindows()}> <Show when={windows() && !electronWindows()}>
<div data-tauri-decorum-tb class="flex flex-row" /> <div data-tauri-decorum-tb class="flex flex-row" />

View file

@ -87,7 +87,6 @@
[data-slot="project-avatar-surface"] [data-slot="project-avatar-image"] { [data-slot="project-avatar-surface"] [data-slot="project-avatar-image"] {
position: relative; position: relative;
z-index: 1;
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;