fix(desktop): ⌘W closes visible file tab when preview selection is stale (#68639)

* fix(desktop): make ⌘W close visible file tab on stale preview selection

When the live preview target is gone but $rightRailActiveTabId still points
at preview, file tabs remain on screen while ⌘W fell through to a workspace
no-op. Close the visible file tab instead.

* test(desktop): cover ⌘W close for file tabs and ghost preview selection

Lock the happy path and the stale-preview regression so ⌘W keeps closing
the file tab the rail is actually showing.
This commit is contained in:
xxxigm 2026-07-21 21:17:32 +07:00 committed by GitHub
parent d355e0e71d
commit d9dae17e97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 118 additions and 7 deletions

View File

@ -0,0 +1,65 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $rightRailActiveTabId, RIGHT_RAIL_PREVIEW_TAB_ID } from '@/store/layout'
import {
$filePreviewTabs,
$previewTarget,
clearSessionPreviewRegistry,
type PreviewTarget,
setCurrentSessionPreviewTarget
} from '@/store/preview'
import { $activeSessionId, $selectedStoredSessionId } from '@/store/session'
import { closeActiveTab } from './close-tab'
function fileTarget(path: string): PreviewTarget {
return {
kind: 'file',
label: path,
path,
previewKind: 'text',
source: path,
url: `file://${path}`
}
}
describe('closeActiveTab', () => {
beforeEach(() => {
vi.stubGlobal('document', { activeElement: null })
$activeSessionId.set('session-1')
$selectedStoredSessionId.set(null)
window.localStorage.clear()
clearSessionPreviewRegistry()
})
afterEach(() => {
vi.unstubAllGlobals()
$activeSessionId.set(null)
$selectedStoredSessionId.set(null)
clearSessionPreviewRegistry()
window.localStorage.clear()
})
it('closes the active file preview tab (⌘W happy path)', () => {
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
expect($filePreviewTabs.get()).toHaveLength(1)
expect($rightRailActiveTabId.get()).toBe('file:file:///work/notes.md')
expect(closeActiveTab()).toBe(true)
expect($filePreviewTabs.get()).toHaveLength(0)
})
it('closes the visible file tab when active selection is a ghost preview', () => {
// Active tab id stuck on live-preview after that target was cleared, while
// file tabs remain (UI falls back to tabs[0] until React syncs). ⌘W must
// close the visible file tab instead of no-op'ing via closeWorkspaceTab().
setCurrentSessionPreviewTarget(fileTarget('/work/notes.md'), 'manual')
$previewTarget.set(null)
$rightRailActiveTabId.set(RIGHT_RAIL_PREVIEW_TAB_ID)
expect($filePreviewTabs.get()).toHaveLength(1)
expect(closeActiveTab()).toBe(true)
expect($filePreviewTabs.get()).toHaveLength(0)
})
})

View File

@ -1,12 +1,12 @@
import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $filePreviewTarget, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
/**
* W close the tab of the context you're in, by precedence:
* 1. a focused terminal its active terminal tab,
* 2. an open preview its active preview tab (unchanged from pre-tiling),
* 2. right-rail tabs (live preview and/or file peeks),
* 3. the MAIN zone its active tab (a session tile stacked into the workspace).
* 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)
@ -19,10 +19,13 @@ export function closeActiveTab(): boolean {
return true
}
if ($filePreviewTarget.get() || $previewTarget.get()) {
closeActiveRightRailTab()
return true
// Prefer tab *presence* over the derived active file target. After the live
// preview is cleared, `$rightRailActiveTabId` can stay on `preview` while
// file tabs remain (the rail UI falls back to tabs[0]). Gating only on
// `$filePreviewTarget` made ⌘W fall through to closeWorkspaceTab() and look
// broken with a file tab still on screen.
if ($previewTarget.get() || $filePreviewTabs.get().length > 0) {
return closeActiveRightRailTab()
}
return closeWorkspaceTab()

View File

@ -87,6 +87,16 @@ if (
selectRightRailTab(RIGHT_RAIL_PREVIEW_TAB_ID)
}
// Inverse: persisted/default active id is still the live-preview tab, but that
// target isn't open and file tabs are. Point at the first file tab so ⌘W and
// the strip agree before React's fallback sync runs.
if (
$rightRailActiveTabId.get() === RIGHT_RAIL_PREVIEW_TAB_ID &&
$filePreviewTabs.get().length > 0
) {
selectRightRailTab($filePreviewTabs.get()[0]!.id)
}
export const $filePreviewTarget = computed([$filePreviewTabs, $rightRailActiveTabId], (tabs, activeTabId) => {
if (!activeTabId.startsWith('file:')) {
return null
@ -460,7 +470,40 @@ export function closeRightRailTab(tabId: RightRailTabId) {
closeFilePreviewTab(tabId)
}
export const closeActiveRightRailTab = () => closeRightRailTab($rightRailActiveTabId.get())
/** Close the tab the right rail is actually showing. Returns false when nothing
* closed (so W can fall through). Resolves a stale `preview` selection to the
* first file tab when the live preview target is already gone. */
export function closeActiveRightRailTab(): boolean {
let tabId = $rightRailActiveTabId.get()
if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID && !$previewTarget.get()) {
const fallback = $filePreviewTabs.get()[0]?.id
if (!fallback) {
return false
}
tabId = fallback
}
if (tabId === RIGHT_RAIL_PREVIEW_TAB_ID) {
if (!$previewTarget.get()) {
return false
}
closeRightRailTab(tabId)
return true
}
if (!$filePreviewTabs.get().some(tab => tab.id === tabId)) {
return false
}
closeRightRailTab(tabId)
return true
}
// The rail's visible tab order: the live preview tab (when present) first, then
// the file tabs in their stored order. Mirrors `ChatPreviewRail`'s `tabs` memo