Skip to content
Clone

fix(repo-browse): fold overflowing section tabs into a menu

A narrow repository column cannot show every tab label, and the previous strip
scrolled them out of reach. The strip now measures each label and hands the
overflow to a shared trigger, keeping a slot for the section being viewed so the
current tab never hides inside the menu; it re-measures when the column resizes,
when the locale changes and once webfonts land.
ZhenYi committed
parent fb232b6 commit 6449e39 Browse files

4 files changed

+198 −26

File tree 4
Modified web/src/pages/workspace/ui/layout/repo-layout.tsx View web/src/pages/workspace/ui/layout/repo-layout.tsx
@@ -1,5 +1,5 @@
1import { useState, type ReactNode } from 'react'
2import { ChevronDown, File, Folder, FolderGit2, GitBranch, Info, Tag } from 'lucide-react'
1import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
2import { ChevronDown, File, Folder, FolderGit2, GitBranch, Info, MoreHorizontal, Tag } from 'lucide-react'
33import { useT, type MessageKey } from '@/shared/i18n'
44import { AppShell, ShellTopBar, type AppShellProps } from '@/shared/ui'
55import type { RepoTabId } from '../../model/route'
@@ -108,6 +108,28 @@
108108/** The PR detail route (`?tab=pull`) belongs to the 合并请求 tab. */
109109const tabGroup = (id: RepoTabId): RepoTabId => (id === 'pull' ? 'pulls' : id)
110110
111/** One label's chrome; the probe reuses it so measured widths match the real
112 * buttons exactly (the active variant only swaps colours and a shadow). */
113const TAB_ITEM_CLASS =
114 'flex h-full shrink-0 items-center whitespace-nowrap rounded-lg px-3 text-[13.5px] font-medium transition-colors'
115
116const tabItemClass = (isActive: boolean) =>
117 `${TAB_ITEM_CLASS} ${
118 isActive
119 ? 'bg-shell-surface text-shell-ink shadow-[inset_0_0_0_1px_var(--shell-hairline)]'
120 : 'text-shell-ink-muted hover:text-shell-ink'
121 }`
122
123/** Width reserved for the "…" trigger when part of the strip is hidden. */
124const MORE_WIDTH = 28
125
126/**
127 * Repository section tabs. A narrow main column cannot show every label, so the
128 * strip measures each one and hands whatever does not fit to a "…" menu instead
129 * of scrolling it out of reach. The section being viewed always keeps a slot:
130 * when it would fall past the edge it swaps into the last one, so the current
131 * tab never hides behind the menu.
132 */
111133function DefaultTabs({
112134 active,
113135 onChange,
@@ -115,32 +137,156 @@
115137 active: NonNullable<RepoLayoutProps['activeTab']>
116138 onChange?: (tab: RepoTabId) => void
117139}) {
118 const { t } = useT()
140 const { t, locale } = useT()
141 const stripRef = useRef<HTMLElement>(null)
142 const probeRef = useRef<HTMLElement>(null)
143 const menuRef = useRef<HTMLDivElement>(null)
144 const triggerRef = useRef<HTMLButtonElement>(null)
145 const [fit, setFit] = useState(tabLabels.length)
146 const [menuOpen, setMenuOpen] = useState(false)
147
148 const ids = tabLabels.map(([id]) => id)
149 const activeIndex = Math.max(
150 0,
151 tabLabels.findIndex(([id]) => tabGroup(active) === id),
152 )
153 const shown = fit >= ids.length ? ids : ids.slice(0, fit)
154 if (fit < ids.length && activeIndex >= fit) shown[fit - 1] = ids[activeIndex]
155 const hidden = tabLabels.filter(([id]) => !shown.includes(id))
156 const overflow = menuOpen && hidden.length > 0
157
158 useLayoutEffect(() => {
159 if (!onChange) return
160 const strip = stripRef.current
161 const probe = probeRef.current
162 if (!strip || !probe) return
163 const measure = () => {
164 const items = Array.from(probe.children) as HTMLElement[]
165 if (items.length === 0) return
166 const probeStyle = getComputedStyle(probe)
167 const gap = Number.parseFloat(probeStyle.columnGap) || 0
168 const pad =
169 (Number.parseFloat(probeStyle.paddingLeft) || 0) + (Number.parseFloat(probeStyle.paddingRight) || 0)
170 const available = strip.clientWidth - pad
171 let used = 0
172 let count = 0
173 for (let index = 0; index < items.length; index += 1) {
174 const width = items[index].getBoundingClientRect().width
175 const need = width + (count > 0 ? gap : 0)
176 // While labels remain after this one the trigger has to fit beside them.
177 const reserve = index < items.length - 1 ? gap + MORE_WIDTH : 0
178 if (count > 0 && used + need + reserve > available) break
179 used += need
180 count += 1
181 }
182 const next = Math.max(1, count)
183 setFit(prev => (prev === next ? prev : next))
184 }
185 measure()
186 const observer = new ResizeObserver(measure)
187 observer.observe(strip)
188 // Label widths follow the locale, and webfonts land after first paint.
189 document.fonts?.ready.then(measure).catch(() => {})
190 return () => observer.disconnect()
191 }, [onChange, locale])
192
193 useEffect(() => {
194 if (!menuOpen) return
195 const onPointerDown = (event: PointerEvent) => {
196 if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false)
197 }
198 const onKeyDown = (event: KeyboardEvent) => {
199 if (event.key !== 'Escape') return
200 setMenuOpen(false)
201 triggerRef.current?.focus()
202 }
203 document.addEventListener('pointerdown', onPointerDown)
204 document.addEventListener('keydown', onKeyDown)
205 return () => {
206 document.removeEventListener('pointerdown', onPointerDown)
207 document.removeEventListener('keydown', onKeyDown)
208 }
209 }, [menuOpen])
210
211 if (!onChange) {
212 return (
213 <nav className="flex h-9 w-full items-center gap-1 rounded-[11px] border border-shell-hairline bg-shell-field p-[3px]">
214 {tabLabels.map(([id, key]) => (
215 <span key={id} className={tabItemClass(tabGroup(active) === id)}>
216 {t(key)}
217 </span>
218 ))}
219 </nav>
220 )
221 }
222
119223 return (
120 <nav className="flex h-9 items-center gap-1 rounded-[11px] border border-shell-hairline bg-shell-field p-[3px]">
121 {tabLabels.map(([id, key]) => {
122 const isActive = tabGroup(active) === id
123 const cls = `flex h-full shrink-0 items-center rounded-lg px-3 text-[13.5px] font-medium transition-colors ${
124 isActive
125 ? 'bg-shell-surface text-shell-ink shadow-[inset_0_0_0_1px_var(--shell-hairline)]'
126 : 'text-shell-ink-muted hover:text-shell-ink'
127 }`
128 return onChange ? (
224 <nav
225 ref={stripRef}
226 className="relative flex h-9 w-full items-center gap-1 rounded-[11px] border border-shell-hairline bg-shell-field p-[3px]"
227 >
228 <nav
229 ref={probeRef}
230 aria-hidden="true"
231 className="pointer-events-none fixed -left-[9999px] top-0 flex items-center gap-1 p-[3px] opacity-0"
232 >
233 {tabLabels.map(([id, key]) => (
234 <span key={id} className={`${TAB_ITEM_CLASS} text-shell-ink-muted`}>
235 {t(key)}
236 </span>
237 ))}
238 </nav>
239 {tabLabels
240 .filter(([id]) => shown.includes(id))
241 .map(([id, key]) => (
129242 <button
130243 key={id}
131244 type="button"
132 aria-current={isActive ? 'page' : undefined}
245 aria-current={tabGroup(active) === id ? 'page' : undefined}
133246 onClick={() => onChange(id)}
134 className={cls}
247 className={tabItemClass(tabGroup(active) === id)}
135248 >
136249 {t(key)}
137250 </button>
138 ) : (
139 <span key={id} className={cls}>
140 {t(key)}
141 </span>
142 )
143 })}
251 ))}
252 {hidden.length > 0 ? (
253 <div ref={menuRef} className="relative ml-auto flex h-full items-center">
254 <button
255 ref={triggerRef}
256 type="button"
257 aria-haspopup="menu"
258 aria-expanded={overflow}
259 aria-label={t('shell.repo.tabs.more')}
260 title={t('shell.repo.tabs.more')}
261 onClick={() => setMenuOpen(open => !open)}
262 className="repo-tabs-more"
263 >
264 <MoreHorizontal className="h-4 w-4" aria-hidden />
265 </button>
266 {overflow ? (
267 <div
268 role="menu"
269 aria-label={t('shell.repo.tabs.more')}
270 className="repo-tabs-menu absolute right-0 top-full z-40 mt-2 w-40 rounded-lg border border-shell-hairline bg-shell-surface p-1.5 shadow-xl"
271 >
272 {hidden.map(([id, key]) => (
273 <button
274 key={id}
275 type="button"
276 role="menuitem"
277 onClick={() => {
278 onChange(id)
279 setMenuOpen(false)
280 }}
281 className="flex w-full items-center px-2.5 text-left text-[13px] font-medium text-shell-ink-muted transition-colors hover:text-shell-ink"
282 >
283 {t(key)}
284 </button>
285 ))}
286 </div>
287 ) : null}
288 </div>
289 ) : null}
144290 </nav>
145291 )
146292}
Modified web/src/pages/workspace/ui/repo/repo-polish.css View web/src/pages/workspace/ui/repo/repo-polish.css
@@ -5,6 +5,28 @@
55.repo-toolbar-refs > .repo-refs > button { min-height: 36px; border: 1px solid var(--shell-hairline); border-radius: 9px; background: var(--shell-field); }
66.repo-toolbar-tabs nav { box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
77.repo-toolbar-tabs button, .repo-toolbar-tabs span { transition: color 160ms, background 160ms; }
8/* The toolbar stays one row while the ref chips, the tab strip and the overview
9 toggle still fit together; once the chips take a line of their own the toggle
10 stays glued to the right end of the tab line. The strip takes the rest of the
11 width but never the toggle's. Scoped through .repo-toolbar so these win over
12 the shell defaults, which load first. */
13.repo-toolbar .repo-toolbar-tabs { flex: 1 1 20rem; min-width: 20rem; max-width: calc(100% - 3rem); }
14.repo-toolbar-tabs > nav { flex: 0 0 auto; }
15/* Labels that do not fit move into the "…" menu, so the strip no longer
16 scrolls — and it must not clip that menu, which hangs below the toolbar. */
17.repo-toolbar .repo-toolbar-tabs { overflow: visible; }
18/* The "…" trigger takes exactly the width the tab measurement reserves for it. */
19.repo-tabs-more { display: flex; height: 100%; width: 1.75rem; flex-shrink: 0; align-items: center; justify-content: center; border-radius: 8px; color: var(--shell-ink-muted); transition: color 160ms, background 160ms; }
20.repo-tabs-more:hover, .repo-tabs-more[aria-expanded='true'] { color: var(--shell-ink); background: var(--shell-surface); }
21.repo-tabs-menu button { min-height: 32px; border-radius: 7px; }
22.repo-tabs-menu button:hover { background: var(--shell-field); }
23.repo-toolbar .repo-toolbar-clone.empty { display: flex; flex: 0 0 auto; margin-left: auto; }
24@media (max-width: 1200px) {
25 .repo-toolbar .repo-toolbar-refs { flex: 1 1 100%; }
26}
27@media (max-width: 480px) {
28 .repo-toolbar .repo-toolbar-tabs { min-width: 12rem; }
29}
830.repo-clone { min-height: 36px; border: 1px solid var(--shell-hairline); border-radius: 9px; background: var(--shell-field); }
931.repo-content-layout [data-repo-scroll] { padding-right: 2px; }
1032.repo-section-toolbar { min-height: 42px; }
@@ -12,13 +34,17 @@
1234.repo-file-toolbar { min-height: 44px; padding: 10px 12px; border: 1px solid var(--shell-hairline); border-radius: 12px; background: var(--shell-surface); }
1335.repo-code-view { display: flex; flex-direction: column; gap: 12px; }
1436.repo-code-header { padding: 6px 8px; border: 1px solid var(--shell-hairline); border-radius: 12px; background: var(--shell-surface); box-shadow: 0 1px 2px rgb(24 28 31 / 2%); }
15.repo-code-header-path { min-width: 0; flex: 0 1 auto; overflow: hidden; white-space: nowrap; }
37.repo-code-header-path { min-width: 0; flex: 1 1 110px; overflow: hidden; white-space: nowrap; }
1638.repo-code-header-path.repo-breadcrumbs { flex-wrap: nowrap; }
1739.repo-code-header-path button { white-space: nowrap; }
18.repo-code-header-search { display: flex; flex: 1 1 240px; min-width: 160px; align-items: center; }
40.repo-code-header-search { display: flex; flex: 1 1 190px; min-width: 150px; align-items: center; }
1941.repo-code-header-search > * { width: 100%; }
20.repo-code-header-clone { flex: 0 1 auto; min-width: 0; }
21.repo-code-header-clone > .repo-clone { width: auto; min-width: 0; max-width: 100%; }
42/* Breadcrumbs + search share the first row while the column can hold them at a
43 usable width; the clone box drops to a row of its own as soon as it cannot
44 join them without squeezing the search into a sliver. Everything grows, so
45 whichever row it lands on is filled to the edge instead of leaving a gap. */
46.repo-code-header-clone { flex: 1 1 240px; min-width: 168px; }
47.repo-code-header-clone > .repo-clone { width: 100%; min-width: 0; max-width: 100%; }
2248.repo-directory-header { padding: 9px 12px; border-bottom: 1px solid var(--shell-hairline); background: color-mix(in srgb, var(--shell-field) 45%, var(--shell-surface)); }
2349.repo-tree-card .repo-embed { border-radius: 0; }
2450.repo-breadcrumbs button { border-radius: 6px; padding: 3px 5px; transition: color 160ms, background 160ms; }
@@ -67,8 +93,6 @@
6793 .repo-file-toolbar { padding: 8px 10px; }
6894 .repo-directory-header { padding: 8px 10px; }
6995 .repo-code-header { padding: 8px; }
70 .repo-code-header-search { flex: 1 1 100%; order: 3; margin-inline: 0; }
71 .repo-code-header-clone { flex: 1 1 100%; order: 4; }
7296 .repo-code-header-clone > .repo-clone { width: 100%; }
7397 .repo-file-table th, .repo-file-table td { padding-left: 12px; padding-right: 12px; }
7498 .repo-commit-row { padding: 14px; }
Modified web/src/shared/i18n/messages/en/shell.ts View web/src/shared/i18n/messages/en/shell.ts
@@ -51,6 +51,7 @@
5151 'shell.repo.tabs.releases': 'Releases',
5252 'shell.repo.tabs.ai': 'AI summary',
5353 'shell.repo.tabs.settings': 'Settings',
54 'shell.repo.tabs.more': 'More',
5455 'shell.repo.loading': 'Loading repository…',
5556 'shell.repo.notFound': 'The repository does not exist or you cannot access it',
5657 'shell.repo.loadFailed': 'Could not load the repository',
Modified web/src/shared/i18n/messages/zh-CN/shell.ts View web/src/shared/i18n/messages/zh-CN/shell.ts
@@ -51,6 +51,7 @@
5151 'shell.repo.tabs.releases': '发布',
5252 'shell.repo.tabs.ai': 'AI 总结',
5353 'shell.repo.tabs.settings': '设置',
54 'shell.repo.tabs.more': '更多',
5455 'shell.repo.loading': '正在加载仓库…',
5556 'shell.repo.notFound': '仓库不存在或你没有访问权限',
5657 'shell.repo.loadFailed': '仓库加载失败',