Merge pull request #75127 from NousResearch/bb/close-last-tab
desktop: closing the last main tab lands on New session, and middle-click works on a real mouse
This commit is contained in:
commit
ab158e8088
|
|
@ -1,9 +1,30 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const closeFocusedSessionTab = vi.fn(() => false)
|
||||
const nextSessionTileForWorkspace = vi.fn<() => null | string>(() => null)
|
||||
const closeSessionTile = vi.fn()
|
||||
const requestFreshSession = vi.fn()
|
||||
|
||||
vi.mock('@/components/pane-shell/tree/store', () => ({
|
||||
closeFocusedSessionTab: () => closeFocusedSessionTab()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/session-states', () => ({
|
||||
closeSessionTile: (...args: unknown[]) => closeSessionTile(...args),
|
||||
nextSessionTileForWorkspace: () => nextSessionTileForWorkspace()
|
||||
}))
|
||||
|
||||
vi.mock('@/store/profile', () => ({
|
||||
requestFreshSession: () => requestFreshSession()
|
||||
}))
|
||||
|
||||
import { $rightRailActiveTabId } from '@/store/layout'
|
||||
import { $previewTabs, closeRightRail, openPreview, type PreviewTarget } from '@/store/preview'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
|
||||
|
||||
import { closeActiveTab } from './close-tab'
|
||||
import { $workspaceIsPage } from '../routes'
|
||||
|
||||
import { closeActiveTab, closeWorkspaceTab } from './close-tab'
|
||||
|
||||
function fileTarget(path: string): PreviewTarget {
|
||||
return {
|
||||
|
|
@ -16,19 +37,31 @@ function fileTarget(path: string): PreviewTarget {
|
|||
}
|
||||
}
|
||||
|
||||
/** Main is holding a loaded chat and nothing else is stacked with it. */
|
||||
function loadedMainOnly() {
|
||||
$selectedStoredSessionId.set('stored-a')
|
||||
$activeSessionId.set('runtime-a')
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('document', { activeElement: null })
|
||||
closeRightRail()
|
||||
window.localStorage.clear()
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
$workspaceIsPage.set(false)
|
||||
closeFocusedSessionTab.mockReturnValue(false)
|
||||
nextSessionTileForWorkspace.mockReturnValue(null)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
closeRightRail()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
describe('closeActiveTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('document', { activeElement: null })
|
||||
closeRightRail()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
closeRightRail()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('closes the active file preview tab (⌘W happy path)', () => {
|
||||
openPreview(fileTarget('/work/notes.md'), 'manual')
|
||||
|
||||
|
|
@ -50,3 +83,56 @@ describe('closeActiveTab', () => {
|
|||
expect($previewTabs.get()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* The main tab's own close. The workspace pane can never leave the tree, so
|
||||
* every answer here is about what FILLS it — a stacked session, or an empty
|
||||
* draft. The gesture used to dead-end whenever main was the only tab.
|
||||
*/
|
||||
describe('closeWorkspaceTab', () => {
|
||||
it('shifts the next stacked session into main', () => {
|
||||
loadedMainOnly()
|
||||
nextSessionTileForWorkspace.mockReturnValue('stored-b')
|
||||
const load = vi.fn()
|
||||
|
||||
expect(closeWorkspaceTab(load)).toBe(true)
|
||||
expect(closeSessionTile).toHaveBeenCalledWith('stored-b')
|
||||
expect(load).toHaveBeenCalledWith('stored-b')
|
||||
// Promotion refills main — it must not ALSO blank it.
|
||||
expect(requestFreshSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('drops a lone loaded main to a fresh draft', () => {
|
||||
loadedMainOnly()
|
||||
|
||||
expect(closeWorkspaceTab(vi.fn())).toBe(true)
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('empties main even with no session loader wired', () => {
|
||||
loadedMainOnly()
|
||||
|
||||
expect(closeWorkspaceTab()).toBe(true)
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is a no-op on a blank draft — that IS the post-close state', () => {
|
||||
expect(closeWorkspaceTab(vi.fn())).toBe(false)
|
||||
expect(requestFreshSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op over a full-page view, which owns no chat tab', () => {
|
||||
loadedMainOnly()
|
||||
$workspaceIsPage.set(true)
|
||||
|
||||
expect(closeWorkspaceTab(vi.fn())).toBe(false)
|
||||
expect(requestFreshSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('⌘W reaches it once the terminal, rail and zone tabs pass', () => {
|
||||
loadedMainOnly()
|
||||
|
||||
expect(closeActiveTab(vi.fn())).toBe(true)
|
||||
expect(requestFreshSession).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,27 +1,68 @@
|
|||
import { mainChatOccupied } from '@/app/open-session'
|
||||
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { $workspaceIsPage } from '@/app/routes'
|
||||
import { closeFocusedSessionTab } from '@/components/pane-shell/tree/store'
|
||||
import { isFocusWithin } from '@/lib/keybinds/combo'
|
||||
import { $previewTabs, closeActiveRightRailTab } from '@/store/preview'
|
||||
import { requestFreshSession } from '@/store/profile'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
|
||||
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
|
||||
|
||||
/**
|
||||
* Close the MAIN tab. The workspace pane itself can't leave the tree, so
|
||||
* "closing" it means emptying it, and what fills the hole depends on what's
|
||||
* stacked beside it:
|
||||
*
|
||||
* - session tabs stacked with it → the next one shifts INTO main (drop its
|
||||
* tile, load it as the primary — the session stays alive, no busy prompt),
|
||||
* - nothing stacked → main drops to a fresh "New session" draft.
|
||||
*
|
||||
* The second half is what makes the gesture honest when main is the ONLY tab:
|
||||
* ⌘W / ⌘-click / middle-click used to be a dead key there, since the only
|
||||
* available answer was "remove the pane", which this app never does.
|
||||
*
|
||||
* Returns false when there is nothing to close — a blank draft (already the
|
||||
* post-close state) or a full-page view (skills / artifacts, which isn't a
|
||||
* chat and owns no tab). ⌘W then stays a no-op; it never closes the window.
|
||||
*
|
||||
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
|
||||
* into main"; omitting it disables the promotion half.
|
||||
*/
|
||||
export function closeWorkspaceTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
|
||||
// Order matters — close the tile FIRST so the selection homes to the
|
||||
// workspace instead of re-fronting the tile.
|
||||
if (loadSessionIntoWorkspace) {
|
||||
const next = nextSessionTileForWorkspace()
|
||||
|
||||
if (next) {
|
||||
closeSessionTile(next)
|
||||
loadSessionIntoWorkspace(next)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if ($workspaceIsPage.get() || !mainChatOccupied($activeSessionId.get(), $selectedStoredSessionId.get())) {
|
||||
return false
|
||||
}
|
||||
|
||||
requestFreshSession()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* ⌘W — close the tab of the context you're in, by precedence:
|
||||
* 1. a focused terminal → its active terminal tab,
|
||||
* 2. right-rail tabs (live preview and/or file peeks),
|
||||
* 3. the FOCUSED chat zone → its active tab (a session tile stacked into it).
|
||||
* 4. the workspace tab itself, when session tabs are stacked with it:
|
||||
* the workspace can't close, so ⌘W shifts the NEXT session tab into main
|
||||
* (loads it as the primary + drops its now-redundant tile).
|
||||
* 4. the workspace tab itself — see `closeWorkspaceTab`.
|
||||
* Returns false when nothing closes, so ⌘W is a no-op — it never closes the
|
||||
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
|
||||
* and the macOS menu-accelerator IPC.
|
||||
* window. Shared by the keyboard path (Win/Linux) and the macOS
|
||||
* menu-accelerator IPC.
|
||||
*
|
||||
* Steps 3-4 follow the same focused zone ⌘1…⌘9 indexes, so a second chat zone
|
||||
* with its own tab strip closes ITS tab instead of main's.
|
||||
*
|
||||
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
|
||||
* into main" (the two call sites have router access); omitting it disables the
|
||||
* step-4 promotion (⌘W stays the pre-existing no-op on the main tab).
|
||||
*/
|
||||
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
|
||||
if (isFocusWithin('[data-terminal]')) {
|
||||
|
|
@ -43,21 +84,5 @@ export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: stri
|
|||
return true
|
||||
}
|
||||
|
||||
// The main (workspace) tab is active and can't be closed — but if session
|
||||
// tabs are stacked with it, ⌘W shifts the next one into the main tab: drop
|
||||
// its tile (the session stays alive, no busy-close prompt) and load it into
|
||||
// main. Order matters — close the tile FIRST so the selection homes to the
|
||||
// workspace instead of re-fronting the tile.
|
||||
if (loadSessionIntoWorkspace) {
|
||||
const next = nextSessionTileForWorkspace()
|
||||
|
||||
if (next) {
|
||||
closeSessionTile(next)
|
||||
loadSessionIntoWorkspace(next)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return closeWorkspaceTab(loadSessionIntoWorkspace)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
|
|||
import { formatRefValue } from '@/components/assistant-ui/directive-text'
|
||||
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
|
||||
import { findGroupOfPane } from '@/components/pane-shell/tree/model'
|
||||
import { $layoutTree, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
|
||||
import { $layoutTree, closeTreePane, moveTreePane, setTreeGroupHeaderHidden } from '@/components/pane-shell/tree/store'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
|
||||
import { transcribeAudio } from '@/hermes'
|
||||
|
|
@ -504,9 +504,10 @@ export function SessionTabMenu({
|
|||
}
|
||||
|
||||
/** The MAIN tab's menu: the same session verbs targeting the primary's loaded
|
||||
* session, plus the bar's off switch (the bar sticky-shows once a tab is
|
||||
* ever gained; this is the explicit way back). A fresh draft has no session —
|
||||
* no menu. */
|
||||
* session, plus Close (the tab empties to a fresh draft — the workspace pane
|
||||
* itself never leaves the tree) and the bar's off switch (the bar sticky-shows
|
||||
* once a tab is ever gained; this is the explicit way back). A fresh draft has
|
||||
* no session — no menu. */
|
||||
export function WorkspaceTabMenu({ children }: { children: React.ReactElement }) {
|
||||
const selected = useStore($selectedStoredSessionId)
|
||||
|
||||
|
|
@ -524,7 +525,12 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
|
|||
}
|
||||
|
||||
return (
|
||||
<SessionTabMenu onHideTabBar={hideTabBar} storedSessionId={selected} tabPaneId="workspace">
|
||||
<SessionTabMenu
|
||||
onClose={() => closeTreePane('workspace')}
|
||||
onHideTabBar={hideTabBar}
|
||||
storedSessionId={selected}
|
||||
tabPaneId="workspace"
|
||||
>
|
||||
{children}
|
||||
</SessionTabMenu>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { SessionInfo } from '@/hermes'
|
|||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { middleClickHandlers } from '@/lib/middle-click'
|
||||
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
|
||||
import { coarseElapsed } from '@/lib/time'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
|
@ -168,16 +169,11 @@ function SidebarSessionRowImpl({
|
|||
)}
|
||||
<SidebarRowBody
|
||||
className={cn('z-0 group-hover:pr-12', branchStem && 'pl-3.5')}
|
||||
// Middle-click = open in a new tab (browser muscle memory). Swallow
|
||||
// the mousedown so Chromium doesn't enter autoscroll mode.
|
||||
onAuxClick={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
openSession(session.id, () => undefined, 'tab')
|
||||
}
|
||||
}}
|
||||
// Middle-click = open in a new tab (browser muscle memory).
|
||||
{...middleClickHandlers(() => {
|
||||
triggerHaptic('selection')
|
||||
openSession(session.id, () => undefined, 'tab')
|
||||
})}
|
||||
onClick={event => {
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
|
||||
|
|
@ -213,7 +209,6 @@ function SidebarSessionRowImpl({
|
|||
|
||||
onResume()
|
||||
}}
|
||||
onMouseDown={event => event.button === 1 && event.preventDefault()}
|
||||
>
|
||||
{reorderable ? (
|
||||
<SidebarRowGrab
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import { FindBar } from '@/components/find-bar'
|
|||
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
|
||||
import { NotificationStack } from '@/components/notifications'
|
||||
import { DesktopOnboardingOverlay } from '@/components/onboarding'
|
||||
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
|
||||
import { $newSessionTabAction, registerPaneCloser } from '@/components/pane-shell/tree/store'
|
||||
import { FloatingPet } from '@/components/pet/floating-pet'
|
||||
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
|
||||
import { emitGatewayEvent } from '@/contrib/events'
|
||||
|
|
@ -68,6 +68,7 @@ import { armWakeWord } from '@/store/wake-word'
|
|||
import { isSecondaryWindow } from '@/store/windows'
|
||||
import { useSkinCommand } from '@/themes/use-skin-command'
|
||||
|
||||
import { closeWorkspaceTab } from '../chat/close-tab'
|
||||
import { requestComposerInsert } from '../chat/composer/focus'
|
||||
import { useComposerActions } from '../chat/hooks/use-composer-actions'
|
||||
import { CommandPalette } from '../command-palette'
|
||||
|
|
@ -83,7 +84,14 @@ import { RemoteFolderPicker } from '../right-sidebar/files/remote-picker'
|
|||
import { resetProjectTreeState } from '../right-sidebar/files/use-project-tree'
|
||||
import { PersistentTerminal } from '../right-sidebar/terminal/persistent'
|
||||
import { closeAllTerminals } from '../right-sidebar/terminal/terminals'
|
||||
import { CRON_ROUTE, navigateToWorkspacePage, routeSessionId, SETTINGS_ROUTE, syncWorkspaceRoute } from '../routes'
|
||||
import {
|
||||
CRON_ROUTE,
|
||||
navigateToWorkspacePage,
|
||||
routeSessionId,
|
||||
sessionRoute,
|
||||
SETTINGS_ROUTE,
|
||||
syncWorkspaceRoute
|
||||
} from '../routes'
|
||||
import { SessionPickerOverlay } from '../session-picker-overlay'
|
||||
import { SessionSwitcher } from '../session-switcher'
|
||||
import { useBackgroundQueueDrain } from '../session/hooks/use-background-queue-drain'
|
||||
|
|
@ -819,6 +827,17 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
|||
return () => $newSessionTabAction.set(null)
|
||||
}, [openNewSessionTab])
|
||||
|
||||
// The MAIN tab's Close. The workspace pane can't leave the tree, so its
|
||||
// closer empties it instead: the next stacked session shifts in, else main
|
||||
// drops to a fresh draft. Registering it here is also what gives the tab its
|
||||
// close GESTURE (⌘-click / middle-click) — the strip reads the closer, not
|
||||
// the `uncloseable` flag, so the pane stays undismissable either way.
|
||||
useEffect(() => {
|
||||
registerPaneCloser('workspace', () => void closeWorkspaceTab(id => navigate(sessionRoute(id))))
|
||||
|
||||
return () => registerPaneCloser('workspace')
|
||||
}, [navigate])
|
||||
|
||||
// The controller's entire callback surface, gathered into the stable
|
||||
// `actions` bag. `nextActions` is TS-checked against WiringActions each
|
||||
// render; its fields are copied into the ref object so `actions` keeps one
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
import { Tip, TipHintLabel } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { middleClickHandlers } from '@/lib/middle-click'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $bindings } from '@/store/keybinds'
|
||||
|
||||
|
|
@ -130,18 +131,8 @@ function TerminalRailItem({ active, canCloseOthers, index, term, toggleHint }: T
|
|||
? 'bg-(--chrome-action-hover) text-foreground'
|
||||
: 'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground'
|
||||
)}
|
||||
onAuxClick={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
closeTerminal(term.id)
|
||||
}
|
||||
}}
|
||||
{...middleClickHandlers(() => closeTerminal(term.id))}
|
||||
onClick={() => selectTerminal(term.id)}
|
||||
onMouseDown={event => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
role="tab"
|
||||
type="button"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import {
|
|||
$hiddenTreePanes,
|
||||
$narrowViewport,
|
||||
$newSessionTabAction,
|
||||
$panesWithCloser,
|
||||
$treeDragging,
|
||||
activateTreePane,
|
||||
closeAllTreeTabs,
|
||||
|
|
@ -177,6 +178,7 @@ export function TreeGroup({
|
|||
const hiddenPanes = useStore($hiddenTreePanes)
|
||||
const narrow = useStore($narrowViewport)
|
||||
const newSessionTabAction = useStore($newSessionTabAction)
|
||||
const panesWithCloser = useStore($panesWithCloser)
|
||||
|
||||
const paneFor = (id: string) => panes.find(p => p.id === id)
|
||||
|
||||
|
|
@ -290,6 +292,12 @@ export function TreeGroup({
|
|||
// closes the session, a store-bound pane collapses).
|
||||
const closeTab = (paneId: string) => (isCollapsePane(paneId) ? dismissTreePane(paneId) : closeTreePane(paneId))
|
||||
|
||||
// A pane whose store owns Close keeps the gesture even when the pane itself
|
||||
// is uncloseable — the workspace tab empties to a fresh draft rather than
|
||||
// leaving the tree.
|
||||
const closeableTab = (paneId: string) =>
|
||||
!paneChrome(paneFor(paneId)).uncloseable || panesWithCloser.has(paneId)
|
||||
|
||||
// Collapse/restore a tool panel (or plain minimize elsewhere) — the header
|
||||
// chevron + tap gesture, routed so ⌃`/the titlebar toggle stay truthful.
|
||||
const toggleCollapse = () => (node.minimized ? restoreTreePane(activeId) : collapseTreePane(activeId))
|
||||
|
|
@ -347,7 +355,7 @@ export function TreeGroup({
|
|||
role="tablist"
|
||||
>
|
||||
{shown.map(paneId => {
|
||||
const closeable = !paneChrome(paneFor(paneId)).uncloseable
|
||||
const closeable = closeableTab(paneId)
|
||||
const title = paneFor(paneId)?.title ?? paneId
|
||||
|
||||
return (
|
||||
|
|
@ -414,7 +422,7 @@ export function TreeGroup({
|
|||
{shown.map(paneId => {
|
||||
const isActive = paneId === activeId && !node.minimized
|
||||
const chrome = paneChrome(paneFor(paneId))
|
||||
const closeable = !chrome.uncloseable
|
||||
const closeable = closeableTab(paneId)
|
||||
const title = paneFor(paneId)?.title ?? paneId
|
||||
|
||||
const tab = (
|
||||
|
|
|
|||
|
|
@ -198,9 +198,24 @@ function setDismissed(paneId: string, dismissed: boolean) {
|
|||
const paneClosers: Record<string, () => void> = {}
|
||||
const paneOpeners: Record<string, () => void> = {}
|
||||
|
||||
/** Route a pane's Close through the app store that owns its visibility. */
|
||||
export function registerPaneCloser(paneId: string, close: () => void) {
|
||||
paneClosers[paneId] = close
|
||||
/** Pane ids whose Close an app store owns. True for the main workspace, whose
|
||||
* pane can't leave the tree but whose TAB can still be emptied — the close
|
||||
* GESTURE (⌘-click / middle-click) keys off this rather than `uncloseable`.
|
||||
* An atom, not a lookup: a closer registered by a wiring EFFECT lands after
|
||||
* the strip's first paint, and a plain read would leave that tab gestureless
|
||||
* until something else happened to re-render it. */
|
||||
export const $panesWithCloser = atom<ReadonlySet<string>>(new Set())
|
||||
|
||||
/** Route a pane's Close through the app store that owns its visibility.
|
||||
* Passing no closer unregisters (a wiring effect's cleanup). */
|
||||
export function registerPaneCloser(paneId: string, close?: () => void) {
|
||||
if (close) {
|
||||
paneClosers[paneId] = close
|
||||
} else {
|
||||
delete paneClosers[paneId]
|
||||
}
|
||||
|
||||
$panesWithCloser.set(new Set(Object.keys(paneClosers)))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { PaneTab, PaneTabLabel } from './pane-tab'
|
|||
afterEach(cleanup)
|
||||
|
||||
describe('PaneTab close gestures', () => {
|
||||
it('middle-click (button 1) closes', () => {
|
||||
it('middle-click closes — pointer events only, no auxclick', () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<PaneTab onClose={onClose}>
|
||||
|
|
@ -14,7 +14,9 @@ describe('PaneTab close gestures', () => {
|
|||
</PaneTab>
|
||||
)
|
||||
|
||||
fireEvent(screen.getByText('tab'), new MouseEvent('auxclick', { bubbles: true, button: 1 }))
|
||||
const tab = screen.getByText('tab')
|
||||
fireEvent.pointerDown(tab, { button: 1 })
|
||||
fireEvent.pointerUp(tab, { button: 1 })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as React from 'react'
|
||||
|
||||
import { middleClickHandlers } from '@/lib/middle-click'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/** Inset stroke for a vertical tab rail — content-facing edge. */
|
||||
|
|
@ -60,9 +61,9 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
|||
active = false,
|
||||
dirty = false,
|
||||
onClose,
|
||||
onAuxClick,
|
||||
onMouseDown,
|
||||
onPointerDown,
|
||||
onPointerUp,
|
||||
onClickCapture,
|
||||
vertical = false,
|
||||
side = 'left',
|
||||
|
|
@ -75,6 +76,7 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
|||
// Vertical rails only. Horizontal tabs draw no bottom border — the strip owns
|
||||
// that rule, and a per-tab border stacked a second translucent line over it.
|
||||
const edge = vertical ? (side === 'right' ? 'border-l' : 'border-r') : undefined
|
||||
const middle = middleClickHandlers(onClose)
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -89,16 +91,6 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
|||
)}
|
||||
data-active={active}
|
||||
data-vertical={vertical || undefined}
|
||||
onAuxClick={event => {
|
||||
// Middle-click closes (browser/IDE). Swallow mousedown so Chromium
|
||||
// doesn't autoscroll.
|
||||
if (onClose && event.button === 1) {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
|
||||
onAuxClick?.(event)
|
||||
}}
|
||||
onClickCapture={event => {
|
||||
// Sites whose tab activates on the label's own onClick (the preview
|
||||
// rail) fire it AFTER our pointerdown close — swallow that stray click
|
||||
|
|
@ -111,13 +103,12 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
|||
onClickCapture?.(event)
|
||||
}}
|
||||
onMouseDown={event => {
|
||||
if (onClose && event.button === 1) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
middle.onMouseDown(event)
|
||||
onMouseDown?.(event)
|
||||
}}
|
||||
onPointerDown={event => {
|
||||
middle.onPointerDown(event)
|
||||
|
||||
// ⌘-click closes. Preempt here — the tab strips activate/drag on
|
||||
// pointerdown (drag-session onTap), so we must claim the press before
|
||||
// the shell's own handler starts a drag, and skip it entirely.
|
||||
|
|
@ -131,6 +122,10 @@ export const PaneTab = React.forwardRef<HTMLDivElement, PaneTabProps>(function P
|
|||
|
||||
onPointerDown?.(event)
|
||||
}}
|
||||
onPointerUp={event => {
|
||||
middle.onPointerUp(event)
|
||||
onPointerUp?.(event)
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { middleClickHandlers } from './middle-click'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
/** A middle click as a real three-button mouse delivers it. Chromium on
|
||||
* Windows/Linux swallows the trailing `auxclick` when the press starts
|
||||
* autoscroll, so the gesture may NOT depend on that event. */
|
||||
function middleClick(element: Element, upOn: Element = element) {
|
||||
fireEvent.mouseDown(element, { button: 1 })
|
||||
fireEvent.pointerDown(element, { button: 1 })
|
||||
fireEvent.pointerUp(upOn, { button: 1 })
|
||||
}
|
||||
|
||||
function Target({ action, id = 'target' }: { action?: () => void; id?: string }) {
|
||||
return (
|
||||
<button {...middleClickHandlers(action)} id={id} type="button">
|
||||
{id}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
describe('middleClickHandlers', () => {
|
||||
it('fires without an auxclick — the event Chromium eats when autoscroll starts', () => {
|
||||
const action = vi.fn()
|
||||
render(<Target action={action} />)
|
||||
|
||||
middleClick(screen.getByText('target'))
|
||||
expect(action).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('cancels mousedown so the autoscroll pan widget never appears', () => {
|
||||
render(<Target action={vi.fn()} />)
|
||||
|
||||
const down = fireEvent.mouseDown(screen.getByText('target'), { button: 1 })
|
||||
expect(down).toBe(false) // preventDefault() called
|
||||
})
|
||||
|
||||
it('cancels the middle mousedown even with no action — the surface owns the button', () => {
|
||||
render(<Target />)
|
||||
|
||||
expect(fireEvent.mouseDown(screen.getByText('target'), { button: 1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores left and right buttons', () => {
|
||||
const action = vi.fn()
|
||||
render(<Target action={action} />)
|
||||
|
||||
const target = screen.getByText('target')
|
||||
fireEvent.pointerDown(target, { button: 0 })
|
||||
fireEvent.pointerUp(target, { button: 0 })
|
||||
fireEvent.pointerDown(target, { button: 2 })
|
||||
fireEvent.pointerUp(target, { button: 2 })
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does nothing when the release lands on a different element', () => {
|
||||
const pressed = vi.fn()
|
||||
const released = vi.fn()
|
||||
render(
|
||||
<>
|
||||
<Target action={pressed} id="pressed" />
|
||||
<Target action={released} id="released" />
|
||||
</>
|
||||
)
|
||||
|
||||
middleClick(screen.getByText('pressed'), screen.getByText('released'))
|
||||
expect(pressed).not.toHaveBeenCalled()
|
||||
expect(released).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('a press with no action cannot arm the NEXT element it releases over', () => {
|
||||
const action = vi.fn()
|
||||
render(
|
||||
<>
|
||||
<Target id="inert" />
|
||||
<Target action={action} id="live" />
|
||||
</>
|
||||
)
|
||||
|
||||
middleClick(screen.getByText('inert'), screen.getByText('live'))
|
||||
expect(action).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type * as React from 'react'
|
||||
|
||||
/** `MouseEvent.button` for the middle (wheel) button. */
|
||||
const MIDDLE_BUTTON = 1
|
||||
|
||||
/** Where the current middle press started. One pointer holds one button, so a
|
||||
* single slot is the whole state, and it's only ever compared by identity in
|
||||
* the pointerup right after — a value left behind by a press released
|
||||
* elsewhere is inert, not stale. */
|
||||
let pressedOn: EventTarget | null = null
|
||||
|
||||
/**
|
||||
* Middle-click as a gesture that survives a real three-button mouse.
|
||||
*
|
||||
* `auxclick` is the obvious event and the wrong one to build on. Windows and
|
||||
* Linux Chromium answer a middle press inside a scroller by starting the
|
||||
* AUTOSCROLL pan, and the mouseup that ends the pan is spent stopping it
|
||||
* instead of completing a click — so `auxclick` never arrives. Every surface
|
||||
* carrying this gesture (tab strips, the session list, the terminal rail) is a
|
||||
* scroller, which is why it only ever worked on macOS, where autoscroll
|
||||
* doesn't exist.
|
||||
*
|
||||
* Pointer events fire either way, so the gesture arms on pointerdown and is
|
||||
* spent on the pointerup over the SAME element — press one tab, release on
|
||||
* another and nothing happens (Chrome / VS Code semantics). mousedown's default
|
||||
* dies on every middle press, action or not, so the pan widget can't appear on
|
||||
* a surface that owns the button.
|
||||
*
|
||||
* A plain factory, not a hook: tab strips call it inside `map()`.
|
||||
*/
|
||||
export function middleClickHandlers(action: (() => void) | undefined) {
|
||||
return {
|
||||
onMouseDown: (event: React.MouseEvent) => {
|
||||
if (event.button === MIDDLE_BUTTON) {
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
|
||||
onPointerDown: (event: React.PointerEvent) => {
|
||||
if (event.button === MIDDLE_BUTTON) {
|
||||
pressedOn = action ? event.currentTarget : null
|
||||
}
|
||||
},
|
||||
|
||||
onPointerUp: (event: React.PointerEvent) => {
|
||||
if (event.button !== MIDDLE_BUTTON) {
|
||||
return
|
||||
}
|
||||
|
||||
const armed = pressedOn === event.currentTarget
|
||||
pressedOn = null
|
||||
|
||||
if (armed) {
|
||||
action?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue