fix(ui): compact context menus + viewport-clamped submenu flip
减重:全部右键菜单(FileTree 行菜单 / TabBar tab 菜单 / GitLens 文件与 历史菜单 / FreeWindow 标题菜单)与「+」菜单切到宿主 Menu 原语的 compact 密度(26px 行高 / 12px 字 / 164px 卡片),图标随槽位 16→14,「打开方式」 子菜单图钉 20→16,还原工具型右键菜单的趁手感。 越界:宿主子菜单纯 CSS 定位(从父行向上、向右生长)不受视口钳制——树顶 部右键时「打开方式 ▸」子菜单伸出视口上沿,窄面板(280–400px)时伸出右 沿。新增 menu-flip.ts:菜单打开期间在 <body> 发布 data-dsh-sidebar-submenu ="down|left" 方向 token(y < vh/2 → down;x + 400 > vw → left),layout.css 据此翻转子菜单生长方向与 ::before 悬停桥;属性存续期即作用域,宿主自有 菜单不受影响。刻意不做 max-height 滚动钳制——overflow 裁剪会切断悬停桥。 测试:menu-flip.spec.tsx(token 纯函数 + hook 生命周期 + CSS 契约跨文件 一致性);tab-bar / file-tree 两个 spec 追加翻转向量断言;mount.e2e 追加 compact 行高(≤30px)布局级证据。设计偏差记录同步至 docs/plans/2026-08-22-open-with-menu-design.md。
This commit is contained in:
+20
-14
@@ -32,6 +32,7 @@ import { VscFile, VscFolder, VscFolderOpened, VscLinkExternal, VscPin, VscPinned
|
||||
import { api, downloadUrl, isOutsideWorkspaceMessage, type FsEntry } from './api.ts'
|
||||
import { FenceErrorNotice } from './FenceErrorNotice.tsx'
|
||||
import { IconUploadOutline16, IconVscode16 } from './icons.tsx'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import type { OpenWithTarget } from './open-with.ts'
|
||||
import { relativeTo } from './paths.ts'
|
||||
import { t } from './locales.ts'
|
||||
@@ -147,6 +148,9 @@ export function FileTree(props: {
|
||||
const [copiedPath, setCopiedPath] = useState<string | null>(null)
|
||||
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
|
||||
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
|
||||
// The row menu's "open with" submenu is the one submenu that can tower past
|
||||
// the viewport; publish its flip geometry for layout.css while it is open.
|
||||
useSubmenuFlip(rowMenu)
|
||||
/** Whether a file drag hovers the tree (drives the portaled drop zone). */
|
||||
const [dropOver, setDropOver] = useState(false)
|
||||
/** The directory a drag is hovering right now (null = body, drop to root). */
|
||||
@@ -373,13 +377,14 @@ export function FileTree(props: {
|
||||
if (openWithTargets === undefined || onOpenWith === undefined || openWithTargets.length === 0) return []
|
||||
const pinnedIds = openWithPinned ?? []
|
||||
/** Brand marks for the built-ins (monochrome silhouettes, currentColor);
|
||||
* reveal gets the folder glyph, custom editors a generic code mark. */
|
||||
* reveal gets the folder glyph, custom editors a generic code mark.
|
||||
* The compact menu's icon slot is 14px, so every mark renders at 14. */
|
||||
const itemIcon = (target: OpenWithTarget): ReactNode => {
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={16} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={16} />
|
||||
if (target.id === 'cursor') return <SiCursor size={16} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={16} />
|
||||
return <IconCodeOutline16 size={16} />
|
||||
if (target.kind === 'reveal') return <VscFolderOpened size={14} />
|
||||
if (target.id === 'vscode') return <IconVscode16 size={14} />
|
||||
if (target.id === 'cursor') return <SiCursor size={14} />
|
||||
if (target.id === 'zed') return <SiZedindustries size={14} />
|
||||
return <IconCodeOutline16 size={14} />
|
||||
}
|
||||
const pinned = openWithTargets
|
||||
.filter(target => pinnedIds.includes(target.id))
|
||||
@@ -412,7 +417,7 @@ export function FileTree(props: {
|
||||
onToggleOpenWithPin?.(target.id)
|
||||
}}
|
||||
>
|
||||
{pinnedNow ? <VscPinned size={14} /> : <VscPin size={14} />}
|
||||
{pinnedNow ? <VscPinned size={12} /> : <VscPin size={12} />}
|
||||
</span>
|
||||
</span>
|
||||
),
|
||||
@@ -433,7 +438,7 @@ export function FileTree(props: {
|
||||
<IconChevronRightOutline14 size={14} className={css.openWithChevron} aria-hidden />
|
||||
</span>
|
||||
),
|
||||
icon: <VscLinkExternal size={16} />,
|
||||
icon: <VscLinkExternal size={14} />,
|
||||
submenu,
|
||||
},
|
||||
]
|
||||
@@ -650,22 +655,22 @@ export function FileTree(props: {
|
||||
items={[
|
||||
// The open escapes head the FILE menu (dirs only get copy).
|
||||
...(rowMenu?.isDir === false && onOpenFileNewTab !== undefined
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={16} /> }]
|
||||
? [{ id: 'open-new-tab', label: t('openFileNewTab'), icon: <IconCodeOutline16 size={14} /> }]
|
||||
: []),
|
||||
...(rowMenu?.isDir === false && onOpenFileSide !== undefined
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={16} /> }]
|
||||
? [{ id: 'open-side', label: t('openFileSide'), icon: <VscFolderOpened size={14} /> }]
|
||||
: []),
|
||||
...openWithEntries(),
|
||||
// Download applies to files only (the host route refuses directories).
|
||||
...(rowMenu?.isDir === false
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={16} /> }]
|
||||
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
|
||||
: []),
|
||||
// Upload into a directory (incl. the workspace root row).
|
||||
...(rowMenu?.isDir === true
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={16} /> }]
|
||||
? [{ id: 'upload-here', label: t('uploadHere'), icon: <IconUploadOutline16 size={14} /> }]
|
||||
: []),
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={16} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={16} /> },
|
||||
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
|
||||
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
|
||||
]}
|
||||
onSelect={(id) => {
|
||||
const target = rowMenu
|
||||
@@ -698,6 +703,7 @@ export function FileTree(props: {
|
||||
)
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -279,6 +279,7 @@ export function FreeWindow(props: {
|
||||
else if (id === 'close') onClose()
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (menu === null ? null : new DOMRect(menu.x, menu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { SidebarTab } from './state.ts'
|
||||
import { isAgentTabId } from './state.ts'
|
||||
import { isPinnedVirtualTab } from './pinned.ts'
|
||||
import { useSubmenuFlip } from './menu-flip.ts'
|
||||
import { IconPinOutline16 } from './icons.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -90,6 +91,9 @@ export function TabBar(props: {
|
||||
// The tab right-click context menu: the target tab plus the cursor
|
||||
// position (the portaled Menu anchors there, following the git lens/FileTree).
|
||||
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
|
||||
// The strip sits at the panel's top, so the pin submenu must grow downward;
|
||||
// the flip hook derives that from the cursor y (upper half → "down").
|
||||
useSubmenuFlip(tabMenu)
|
||||
const [dragOver, setDragOver] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
// The context target's index in the render-time tab snapshot; -1 when the
|
||||
@@ -265,6 +269,7 @@ export function TabBar(props: {
|
||||
setMenuOpen(false)
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
@@ -355,6 +360,7 @@ export function TabBar(props: {
|
||||
}
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (tabMenu === null ? null : new DOMRect(tabMenu.x, tabMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -671,6 +671,7 @@ export function GitLens(props: GitLensProps) {
|
||||
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
@@ -728,6 +729,7 @@ export function GitLens(props: GitLensProps) {
|
||||
}
|
||||
}}
|
||||
portal
|
||||
compact
|
||||
align="start"
|
||||
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
|
||||
anchor={<span />}
|
||||
|
||||
@@ -111,3 +111,40 @@ body[data-dsh-sidebar-dragging] #root [data-dsh-center-col],
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Context-menu submenu flip (viewport clamping for the portaled menus).
|
||||
*
|
||||
* The ui-primitives Menu clamps its main portal list into the viewport but
|
||||
* positions nested submenus with static CSS only — anchored at the parent
|
||||
* row's bottom, growing UP and to the RIGHT — with no clamp whatsoever.
|
||||
* Docked on the window's right edge, both defaults are the worst case for
|
||||
* this panel: a tall "open with" submenu escapes past the viewport top when
|
||||
* the row menu opens near the top, and past the right edge on narrow panels
|
||||
* (PANEL_MIN 280 < card 218 + gap + submenu 165).
|
||||
*
|
||||
* menu-flip.ts publishes `data-dsh-sidebar-submenu` on <body> — with the
|
||||
* words "down" and/or "left" — for exactly the lifetime of one of OUR
|
||||
* submenu-bearing menus (FileTree row menu, TabBar tab menu). The tokens
|
||||
* flip the growth direction; the attribute's absence restores the
|
||||
* primitive's own geometry untouched, and host-owned menus are never
|
||||
* affected because they never open while the attribute exists (each side
|
||||
* closes on the other's outside pointerdown).
|
||||
*
|
||||
* No max-height/overflow scroll cap here on purpose: overflow clipping
|
||||
* would cut the submenu's ::before hover bridge (the 10px corridor that
|
||||
* keeps itemWrap's mouseleave from closing the card mid-crossing). */
|
||||
body[data-dsh-sidebar-submenu~="down"] div[role="menu"] div[role="menu"] {
|
||||
top: -4px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"] {
|
||||
left: auto;
|
||||
right: calc(100% + 10px);
|
||||
}
|
||||
|
||||
/* The hover bridge follows the card to the left side of the parent row. */
|
||||
body[data-dsh-sidebar-submenu~="left"] div[role="menu"] div[role="menu"]::before {
|
||||
left: auto;
|
||||
right: -10px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Submenu flip geometry for the portaled context menus.
|
||||
*
|
||||
* The primitives `Menu` clamps its main list into the viewport (portal mode,
|
||||
* 12px margin) but positions nested submenus with pure CSS — `bottom: -4px`
|
||||
* (grow UP from the parent row) and `left: calc(100% + 10px)` (open RIGHT of
|
||||
* the card) — with no viewport clamp at all. This sidebar lives docked on the
|
||||
* RIGHT edge of the window, so both defaults are the worst case here: a tall
|
||||
* "open with" submenu pokes past the viewport top when the row menu opens
|
||||
* near the top, and past the right edge whenever the panel is narrow.
|
||||
*
|
||||
* The host primitive is not ours to patch, so the fix is a body attribute
|
||||
* written while one of OUR submenu-bearing menus is open: layout.css flips
|
||||
* the submenu's growth direction per token. The attribute's lifetime IS the
|
||||
* scope — only our menus set it, and layout.css rules for
|
||||
* `div[role="menu"] div[role="menu"]` only apply while it exists, so
|
||||
* host-owned menus are never touched.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/** body attribute carrying the flip tokens (whitespace-separated words). */
|
||||
export const SUBMENU_FLIP_ATTR = 'data-dsh-sidebar-submenu'
|
||||
|
||||
/**
|
||||
* Mirror of the primitive's submenu geometry (Menu.module.css): card
|
||||
* min-width 218 + card-to-submenu gap 10 + submenu min-width 163..165 +
|
||||
* viewport margin 12 ≈ 400px of right-hand room a right-growing submenu
|
||||
* needs. If the cursor is within that of the right edge, open left instead.
|
||||
* The estimate is conservative (the primitive's own clamp can only move the
|
||||
* card further left), so a wrong guess flips left unnecessarily — never into
|
||||
* a new overflow — and drifting from future host metrics degrades the same
|
||||
* way, back to today's behavior at worst.
|
||||
*/
|
||||
const SUBMENU_RIGHT_ROOM = 400
|
||||
|
||||
/** Below the vertical midpoint the parent row usually has more room above. */
|
||||
function isUpperHalf(y: number, vh: number): boolean {
|
||||
return y < vh / 2
|
||||
}
|
||||
|
||||
/**
|
||||
* The flip tokens for a menu opened at viewport coordinates (x, y):
|
||||
* `down` — open the submenu downward from the parent row (cursor in the
|
||||
* upper half; the sidebar's tab strip is always up there);
|
||||
* `left` — open the submenu leftward of the card (not enough right-hand
|
||||
* room). Empty string when the primitive's defaults already fit.
|
||||
*/
|
||||
export function submenuFlipTokens(x: number, y: number, vw: number, vh: number): string {
|
||||
const tokens: string[] = []
|
||||
if (isUpperHalf(y, vh)) tokens.push('down')
|
||||
if (x + SUBMENU_RIGHT_ROOM > vw) tokens.push('left')
|
||||
return tokens.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the flip tokens on <body> while the given cursor-anchored menu
|
||||
* state is open; clear them on close and on unmount (a menu left open by an
|
||||
* unmounting panel must not leave stale geometry on the shared body).
|
||||
* `menu` only needs the viewport coordinates the Menu anchored to.
|
||||
*/
|
||||
export function useSubmenuFlip(menu: { x: number; y: number } | null): void {
|
||||
useEffect(() => {
|
||||
if (menu === null) return
|
||||
document.body.setAttribute(SUBMENU_FLIP_ATTR, submenuFlipTokens(menu.x, menu.y, window.innerWidth, window.innerHeight))
|
||||
return () => { document.body.removeAttribute(SUBMENU_FLIP_ATTR) }
|
||||
}, [menu])
|
||||
}
|
||||
@@ -2439,15 +2439,16 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
}
|
||||
|
||||
/* The pushpin toggle on an "open with" submenu row: brand ink once the
|
||||
target is pinned to the menu's top level. */
|
||||
target is pinned to the menu's top level. Sized for the compact menu's
|
||||
26px rows (16px box, 12px glyph) — proportionate without overflowing. */
|
||||
.openWithPin {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 5px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function buildNewTabOptions(state: SidebarState, ctx: Context, scope: Ses
|
||||
id: d.id,
|
||||
label: typeof d.title === 'function' ? d.title() : d.title,
|
||||
disabled: !(d.available?.(ctx, scope, state) ?? true),
|
||||
icon: typeof d.icon === 'function' ? d.icon(16) : d.icon,
|
||||
// 14 matches the compact + menu's icon slot (compactList .itemIcon).
|
||||
icon: typeof d.icon === 'function' ? d.icon(14) : d.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user