From 7c59101c4a09512dc742b9c8c00d3aee00df98e5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Thu, 30 Jul 2026 04:22:41 -0500 Subject: [PATCH] fix(desktop): give every tab strip the standard right-click tab menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a tab that has no domain menu of its own — the main tab on a fresh draft, the file tree, a terminal — fell through to the zone strip's menu, which offered Split right/down/left/up and Hide header. Session tabs never showed it (SessionTabMenu stops the event), so the split menu only ever appeared on the surfaces least likely to want it. ZoneMenu now renders the same verbs a session tab's menu does — Close, Close others, Close to the right, Close all — over the shared ActionsContextMenu kit, so both menus stay identical, above the strip's own header/minimize toggles. The Split actions were the only caller of splitTreeZone -> splitGroupZone, and Move was the only caller of adjacentGroup; both chains are removed along with the now-unused direction strings in every locale. Everything else the menu did is still reachable: Move by dragging the tab, Hide header by double-tapping the strip (and from a session tab's Hide tab bar), Minimize from the header chevron. --- .../src/components/pane-shell/tree/model.ts | 111 ---------- .../pane-shell/tree/renderer/tree-group.tsx | 201 ++++++++---------- .../src/components/pane-shell/tree/store.ts | 14 -- apps/desktop/src/i18n/ar.ts | 8 +- apps/desktop/src/i18n/en.ts | 8 +- apps/desktop/src/i18n/ja.ts | 8 +- apps/desktop/src/i18n/types.ts | 6 - apps/desktop/src/i18n/zh-hant.ts | 8 +- apps/desktop/src/i18n/zh.ts | 8 +- 9 files changed, 89 insertions(+), 283 deletions(-) diff --git a/apps/desktop/src/components/pane-shell/tree/model.ts b/apps/desktop/src/components/pane-shell/tree/model.ts index f1d09acdd4d..5d1c39206e6 100644 --- a/apps/desktop/src/components/pane-shell/tree/model.ts +++ b/apps/desktop/src/components/pane-shell/tree/model.ts @@ -320,91 +320,6 @@ export function groupLeafIds(node: LayoutNode): string[] { return node.type === 'group' ? [node.id] : node.children.flatMap(groupLeafIds) } -function pathToGroup(node: LayoutNode, groupId: string): LayoutNode[] | null { - if (node.type === 'group') { - return node.id === groupId ? [node] : null - } - - for (const child of node.children) { - const sub = pathToGroup(child, groupId) - - if (sub) { - return [node, ...sub] - } - } - - return null -} - -const OPPOSITE_EDGE: Record = { bottom: 'top', left: 'right', right: 'left', top: 'bottom' } - -/** The viable group touching `edge` of this subtree. Along the edge's axis - * children are scanned edge-first — a non-viable zone is display:none, so the - * next sibling IS the visual edge; across it, every child touches the edge. */ -function edgeGroup(node: LayoutNode, edge: RootEdge, viable: (g: GroupNode) => boolean): GroupNode | null { - if (node.type === 'group') { - return viable(node) ? node : null - } - - const along = (node.orientation === 'row') === (edge === 'left' || edge === 'right') - const children = along && (edge === 'right' || edge === 'bottom') ? [...node.children].reverse() : node.children - - for (const child of children) { - const hit = edgeGroup(child, edge, viable) - - if (hit) { - return hit - } - } - - return null -} - -/** - * The viable zone VISUALLY adjacent to `groupId` on `side` (the target of the - * zone menu's "Move left/right/up/down"). Walks up to the nearest ancestor - * split running along that axis with a sibling on that side, then descends to - * the sibling's closest viable leaf; subtrees whose every zone fails `viable` - * (all panes hidden) are skipped, matching their collapsed rendering. - */ -export function adjacentGroup( - root: LayoutNode, - groupId: string, - side: RootEdge, - viable: (g: GroupNode) => boolean -): GroupNode | null { - const path = pathToGroup(root, groupId) - - if (!path) { - return null - } - - const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column' - const forward = side === 'right' || side === 'bottom' - - for (let i = path.length - 2; i >= 0; i--) { - const parent = path[i] - - if (parent.type !== 'split' || parent.orientation !== orientation) { - continue - } - - const index = parent.children.indexOf(path[i + 1]) - - const siblings = forward ? parent.children.slice(index + 1) : parent.children.slice(0, index).reverse() - - for (const sibling of siblings) { - const hit = edgeGroup(sibling, OPPOSITE_EDGE[side], viable) - - if (hit) { - return hit - } - } - } - - return null -} - function sameSet(ids: string[], set: Set): boolean { return ids.length === set.size && ids.every(id => set.has(id)) } @@ -523,32 +438,6 @@ function replaceNode(node: LayoutNode, id: string, make: (g: GroupNode) => Layou return { ...node, children: node.children.map(c => replaceNode(c, id, make)) } } -/** - * Split a zone: `movePaneId` (one of SEVERAL panes in the group) moves into - * the new zone on `side` — VS Code "split right", split and move in one - * gesture. A lone pane can't split away from itself: no-op (normalize prunes - * the empty zone the split would have minted). - */ -export function splitGroupZone(root: LayoutNode, groupId: string, side: RootEdge, movePaneId: string): LayoutNode { - const orientation: Orientation = side === 'left' || side === 'right' ? 'row' : 'column' - const before = side === 'left' || side === 'top' - - return ( - normalize( - replaceNode(root, groupId, g => { - if (g.panes.length < 2 || !g.panes.includes(movePaneId)) { - return g - } - - const added = group([movePaneId]) - const remaining = { ...g, panes: g.panes.filter(p => p !== movePaneId) } - - return split(orientation, before ? [added, remaining] : [remaining, added], [1, 1]) - }) - ) ?? root - ) -} - /** Mirror the layout HORIZONTALLY (the titlebar flip toggle / ⌘\): reverse * every ROW split's child order at EVERY depth, so left↔right flips * everywhere. A right rail lands on the left with its OWN internal order diff --git a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx index 6008ca025ff..ff8a4c946ae 100644 --- a/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx +++ b/apps/desktop/src/components/pane-shell/tree/renderer/tree-group.tsx @@ -6,14 +6,14 @@ * * Dragging is FancyZones-style (drag-session.ts): the layout stays fixed and * every zone lights up as a whole-region drop target. Right-click opens the - * contextual zone menu (split/move + header/minimize toggles). + * contextual zone menu (tab close verbs + header/minimize toggles). */ import { useStore } from '@nanostores/react' import { type CSSProperties, Fragment, type ReactNode, type RefObject, useEffect, useRef, useState } from 'react' +import { ActionsContextMenu, type MenuKit, renderActionItem } from '@/components/ui/actions-menu' import { Codicon } from '@/components/ui/codicon' -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu' import { DecodeText } from '@/components/ui/decode-text' import { DROP_SHEET_BLUR_CLASS, DROP_SHEET_CLASS } from '@/components/ui/drop-affordance' import { PANE_TAB_STRIP_LINE_LEFT, PANE_TAB_STRIP_LINE_RIGHT, PaneTab, PaneTabLabel } from '@/components/ui/pane-tab' @@ -25,28 +25,28 @@ import { cn } from '@/lib/utils' import { $layoutEditMode } from '../../edit-mode' import { useWindowControlsOverlap } from '../../geometry' import { hiddenPaneProps, PaneGroupContext, PaneVisibleContext } from '../../pane-visibility' -import type { DropPosition, GroupNode, RootEdge } from '../model' -import { adjacentGroup } from '../model' +import type { DropPosition, GroupNode } from '../model' import { $dropHint, $hiddenTreePanes, - $layoutTree, $narrowViewport, $newSessionTabAction, $treeDragging, activateTreePane, + closeAllTreeTabs, + closeOtherTreeTabs, closeTreePane, + closeTreeTabsToRight, collapseTreePane, dismissTreePane, isCollapsePane, isSessionStripPane, - moveTreePane, noteActiveTreeGroup, restoreTreePane, SESSION_TILE_DRAG, setTreeGroupHeaderHidden, setTreeGroupMinimized, - splitTreeZone + treeTabCloseTargets } from '../store' import { type DoubleTapContext, startPaneDrag } from './drag-session' @@ -54,30 +54,19 @@ import { forceLoneHeaderForPanes } from './lone-header' import { useActiveTabVisible } from './tab-strip-scroll' import { paneChrome } from './track-model' -/** A directional action in the zone menu (computed per group state). */ -interface ZoneMenuDirection { - side: RootEdge - label: string - run: () => void -} - -const DIRECTION_ORDER: readonly RootEdge[] = ['right', 'bottom', 'left', 'top'] -const DIRECTION_ARROW: Record = { bottom: '↓', left: '←', right: '→', top: '↑' } - -/** Right-click zone menu: directional actions + header toggle + minimize. - * The directions are CONTEXTUAL (computed by TreeGroup): a stacked group - * offers "Split " (carve a new zone with the clicked pane — VS Code - * split-and-move in one gesture); a single-pane group offers "Move " - * into the zone actually sitting on that side — directions with no visible - * neighbor aren't offered, so no action ever appears to do nothing. */ +/** Right-click zone menu: the tab verbs (close this / others / to the right / + * all) plus the strip's own chrome toggles. Same items and icons as a session + * tab's menu, so every tab in a strip answers a right-click the same way — + * a pane with no domain menu of its own (the file tree, a terminal, the main + * tab on a fresh draft) falls through to this one. */ function ZoneMenu({ children, closable, minimizable = true, - directions, headerHidden, minimized, - nodeId + nodeId, + targetPane }: { children: ReactNode /** The pane the menu closes (the right-clicked chip / the active pane); @@ -86,52 +75,71 @@ function ZoneMenu({ /** False for the zone hosting the uncloseable workspace — collapsing the * MAIN pane strands the app behind a strip. */ minimizable?: boolean - /** Called when the menu renders, not on every zone re-render: resolving the - * neighbor zones has to read the layout tree, and subscribing every zone to - * it made a sash drag re-render every mounted pane. Same lazy shape as - * `closable`. */ - directions: () => ZoneMenuDirection[] headerHidden?: boolean minimized?: boolean nodeId: string + /** The right-clicked chip (else the active pane) — what the close-others / + * to-the-right / all verbs measure from. Called when the menu RENDERS, not + * on every zone re-render: resolving the siblings reads the layout tree, + * and subscribing every zone to it made a sash drag re-render every + * mounted pane. */ + targetPane: () => string }) { const { t } = useI18n() - return ( - - {children} - - {directions().map(direction => ( - - {direction.label} - - ))} - setTreeGroupHeaderHidden(nodeId, !headerHidden)}> - {headerHidden ? t.zones.showHeader : t.zones.hideHeader} - - {minimizable && ( - setTreeGroupMinimized(nodeId, !minimized)}> - {minimized ? t.zones.restore : t.zones.minimize} - - )} - {/* Resolved at render: the menu mounts on open, after the right-click - set menuPane — so an uncloseable target hides the item instead - of offering a dead action. */} - {closable?.() !== undefined && ( - { - const paneId = closable?.() + // Resolved at render: the menu mounts on open, after the right-click set + // menuPane — so an uncloseable target hides Close instead of offering a + // dead action, and the counts describe the chip actually clicked. + const items = (kit: MenuKit) => { + const paneId = closable?.() + const targets = treeTabCloseTargets(targetPane()) - if (paneId) { - closeTreePane(paneId) - } - }} - > - {t.common.close} - - )} - - + return ( + <> + {paneId !== undefined && + renderActionItem(kit, { + icon: 'close', + label: t.common.close, + onSelect: () => closeTreePane(paneId) + })} + {renderActionItem(kit, { + disabled: !targets.others, + icon: 'close-all', + label: t.zones.closeOthers, + onSelect: () => closeOtherTreeTabs(targetPane()) + })} + {renderActionItem(kit, { + disabled: !targets.right, + icon: 'arrow-right', + label: t.zones.closeToRight, + onSelect: () => closeTreeTabsToRight(targetPane()) + })} + {renderActionItem(kit, { + disabled: !targets.all, + icon: 'clear-all', + label: t.zones.closeAll, + onSelect: () => closeAllTreeTabs(targetPane()) + })} + + {renderActionItem(kit, { + icon: headerHidden ? 'eye' : 'eye-closed', + label: headerHidden ? t.zones.showHeader : t.zones.hideHeader, + onSelect: () => setTreeGroupHeaderHidden(nodeId, !headerHidden) + })} + {minimizable && + renderActionItem(kit, { + icon: minimized ? 'chevron-down' : 'chevron-up', + label: minimized ? t.zones.restore : t.zones.minimize, + onSelect: () => setTreeGroupMinimized(nodeId, !minimized) + })} + + ) + } + + return ( + + {children} + ) } @@ -255,61 +263,20 @@ export function TreeGroup({ } } - const dirWord: Record = { - bottom: t.zones.dirDown, - left: t.zones.dirLeft, - right: t.zones.dirRight, - top: t.zones.dirUp - } - - // Zone-menu directions, contextual to this group's state: - // - stacked panes -> "Split ": carve a new zone on that side with the - // right-clicked chip's pane in it (split + move, one gesture); - // - a single pane -> "Move ": join the zone visually adjacent on that - // side (splitting here would only make an invisible empty zone). Sides - // with no visible neighbor are omitted entirely. - // NOT `useStore($layoutTree)`: this subscribes every zone — and therefore - // every mounted pane and its whole transcript — to the entire layout tree. - // A sash drag rewrites the tree once per frame, so dragging the sidebar - // re-rendered all five tiles' message lists on every pointermove (measured: - // TreeGroup 180 renders cascading into ChatView/Thread/TileChat at ~4.5s - // each, holding the drag at ~3fps). - // - // The tree is only read to build the zone context menu's move/split - // directions, which are consumed when the menu OPENS — so read it at that - // moment with `.get()` instead of subscribing to every intermediate frame. - const menuDirections = (): ZoneMenuDirection[] => { - if (shown.length > 1) { - return DIRECTION_ORDER.map(side => ({ - side, - label: `${t.zones.split(dirWord[side])} ${DIRECTION_ARROW[side]}`, - run: () => splitTreeZone(node.id, side, menuPane ?? activeId) - })) - } - - const tree = $layoutTree.get() - - return DIRECTION_ORDER.flatMap(side => { - const neighbor = tree ? adjacentGroup(tree, node.id, side, g => g.panes.some(paneShown)) : null - - if (!neighbor || neighbor.id === node.id) { - return [] - } - - return [ - { - side, - label: `${t.zones.move(dirWord[side])} ${DIRECTION_ARROW[side]}`, - run: () => moveTreePane(activeId, { groupId: neighbor.id, pos: 'center' }) - } - ] - }) - } + // Zone-menu close targets read the layout tree, but this component must NOT + // subscribe to it: `useStore($layoutTree)` here wires every zone — and + // therefore every mounted pane and its whole transcript — to the entire + // tree. A sash drag rewrites the tree once per frame, so dragging the + // sidebar re-rendered all five tiles' message lists on every pointermove + // (measured: TreeGroup 180 renders cascading into ChatView/Thread/TileChat + // at ~4.5s each, holding the drag at ~3fps). The menu's items are resolved + // when it OPENS, so they read the tree with `.get()` at that moment instead. + const targetPane = () => menuPane ?? activeId // Close targets the right-clicked chip (falling back to the active pane); // only panes that declare `uncloseable` (the main workspace) are exempt. const closable = () => { - const paneId = menuPane ?? activeId + const paneId = targetPane() return paneChrome(paneFor(paneId)).uncloseable ? undefined : paneId } @@ -330,11 +297,11 @@ export function TreeGroup({ // Same menu on the header strip and the edit veil — one prop bag. const zoneMenu = { closable, - directions: menuDirections, headerHidden, minimizable, minimized: node.minimized, - nodeId: node.id + nodeId: node.id, + targetPane } // NO body double-click toggle: virtualized content (the thread) recreates diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index ee95d5e83bc..49ca0587c7e 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -31,12 +31,10 @@ import { normalize, removePane, reorderPaneInGroup as reorderPaneInGroupOp, - type RootEdge, setActivePane as setActivePaneOp, setGroupHeaderHidden as setGroupHeaderHiddenOp, setGroupMinimized, setSplitWeights as setSplitWeightsOp, - splitGroupZone as splitGroupZoneOp, type SplitNode } from './model' import { FLOATING_PLACEMENT } from './renderer/floating-rect' @@ -1205,18 +1203,6 @@ export function reorderTreePane(groupId: string, paneId: string, toIndex: number } } -/** Split a zone on `side`, moving `movePaneId` out of its stack into the new - * zone (VS Code split-and-move — the zone menu's Split actions). */ -export function splitTreeZone(groupId: string, side: RootEdge, movePaneId: string) { - const tree = $layoutTree.get() - - if (tree) { - commit(splitGroupZoneOp(tree, groupId, side, movePaneId)) - markActivePreset('custom') - markPaneUserPlaced(movePaneId) - } -} - export function setTreeGroupMinimized(groupId: string, minimized: boolean) { const tree = $layoutTree.get() diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 81dfb232a7c..30d58189ed8 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2237,17 +2237,11 @@ export const ar = defineLocale({ closeOthers: 'إغلاق الأخرى', closeToRight: 'إغلاق ما على اليمين', closeAll: 'إغلاق الكل', - split: dir => `تقسيم ${dir}`, - move: dir => `نقل ${dir}`, - dirUp: 'للأعلى', - dirDown: 'للأسفل', - dirLeft: 'لليسار', - dirRight: 'لليمين', pluginDisabled: pluginId => `الإضافة "${pluginId}" معطلة`, pluginDisabledBody: 'أعد تفعيلها من الإعدادات ← الإضافات لإرجاع اللوحة.', missingPane: paneId => `لوحة مفقودة: ${paneId}`, editTitle: 'التخطيطات', - editHint: 'اختر تخطيطا، أو اسحب اللوحات بين المناطق. انقر بزر الفأرة الأيمن على منطقة لتقسيمها.', + editHint: 'اختر تخطيطا، أو اسحب اللوحات بين المناطق.', reset: 'إعادة ضبط', templates: 'القوالب', custom: 'مخصص', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index c0be4253843..c8b4d60425c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2657,17 +2657,11 @@ export const en: Translations = { closeToRight: 'Close to the right', closeAll: 'Close all', newSessionTab: 'New session tab', - split: dir => `Split ${dir}`, - move: dir => `Move ${dir}`, - dirUp: 'up', - dirDown: 'down', - dirLeft: 'left', - dirRight: 'right', pluginDisabled: pluginId => `Plugin "${pluginId}" disabled`, pluginDisabledBody: 'Re-enable it in Settings → Plugins to bring the pane back.', missingPane: paneId => `missing pane: ${paneId}`, editTitle: 'Layouts', - editHint: 'Pick a layout, or drag panes between zones. Right-click a zone to split.', + editHint: 'Pick a layout, or drag panes between zones.', reset: 'Reset', templates: 'Templates', custom: 'Custom', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 642dfd321e5..56e7daa1fa8 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2483,17 +2483,11 @@ export const ja = defineLocale({ closeToRight: '右側を閉じる', closeAll: 'すべて閉じる', newSessionTab: '新しいセッションタブ', - split: dir => `${dir}に分割`, - move: dir => `${dir}へ移動`, - dirUp: '上', - dirDown: '下', - dirLeft: '左', - dirRight: '右', pluginDisabled: pluginId => `プラグイン「${pluginId}」を無効化しました`, pluginDisabledBody: '設定 → プラグイン で再有効化するとペインが戻ります。', missingPane: paneId => `ペインが見つかりません: ${paneId}`, editTitle: 'レイアウト', - editHint: 'レイアウトを選ぶか、ペインをゾーン間へドラッグ。ゾーンを右クリックで分割。', + editHint: 'レイアウトを選ぶか、ペインをゾーン間へドラッグ。', reset: 'リセット', templates: 'テンプレート', custom: 'カスタム', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 20e32d7de71..74b7c5afad3 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2257,12 +2257,6 @@ export interface Translations { closeToRight: string closeAll: string newSessionTab: string - split: (dir: string) => string - move: (dir: string) => string - dirUp: string - dirDown: string - dirLeft: string - dirRight: string pluginDisabled: (pluginId: string) => string pluginDisabledBody: string missingPane: (paneId: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 895ee96f9be..0b6041fa104 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2403,17 +2403,11 @@ export const zhHant = defineLocale({ closeToRight: '關閉右側', closeAll: '全部關閉', newSessionTab: '新增工作階段分頁', - split: dir => `向${dir}分割`, - move: dir => `向${dir}移動`, - dirUp: '上', - dirDown: '下', - dirLeft: '左', - dirRight: '右', pluginDisabled: pluginId => `外掛「${pluginId}」已停用`, pluginDisabledBody: '在 設定 → 外掛 中重新啟用即可恢復面板。', missingPane: paneId => `缺少面板:${paneId}`, editTitle: '版面配置', - editHint: '選擇一個版面配置,或在區域之間拖曳面板。右鍵點擊區域可分割。', + editHint: '選擇一個版面配置,或在區域之間拖曳面板。', reset: '重設', templates: '範本', custom: '自訂', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index d852f796fd5..cfdfc8b0d32 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2835,17 +2835,11 @@ export const zh: Translations = { closeToRight: '关闭右侧', closeAll: '全部关闭', newSessionTab: '新建会话标签', - split: dir => `向${dir}拆分`, - move: dir => `向${dir}移动`, - dirUp: '上', - dirDown: '下', - dirLeft: '左', - dirRight: '右', pluginDisabled: pluginId => `插件“${pluginId}”已禁用`, pluginDisabledBody: '在 设置 → 插件 中重新启用即可恢复面板。', missingPane: paneId => `缺少面板:${paneId}`, editTitle: '布局', - editHint: '选择一个布局,或在区域之间拖动面板。右键点击区域可拆分。', + editHint: '选择一个布局,或在区域之间拖动面板。', reset: '重置', templates: '模板', custom: '自定义',