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 094ecd28d8a..9df6e3d44d0 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 5514e5f2db9..0f37ba6d104 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 40cfd8faa42..6733eb49a5d 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 dae2d281acc..fab8e071bf7 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 ab66d9a60cf..88f3ffdf95a 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 ceec11cae4e..8a808dffec8 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 3d1d18c7045..c34d811344e 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: '自定义',