fix(desktop): recover minimized sidebars from their existing controls
Co-authored-by: wukangcheng1994 <160389295+wukangcheng1994@users.noreply.github.com>
This commit is contained in:
@@ -43,7 +43,6 @@ import {
|
||||
} from './model'
|
||||
import { FLOATING_PLACEMENT } from './renderer/floating-rect'
|
||||
import { tabStripVisibleForZone } from './renderer/strip-visibility'
|
||||
import { rootChildSide } from './renderer/track-model'
|
||||
|
||||
// v2: v1 trees were saved against placeholder panes with index-order zone
|
||||
// assignment (chat could land in a corner cell). Retire them wholesale.
|
||||
@@ -894,9 +893,21 @@ export function paneRootSide(paneId: string): null | TreeSide {
|
||||
}
|
||||
|
||||
const panes = registry.getArea('panes')
|
||||
const child = row.children.find(c => allPaneIds(c).includes(paneId))
|
||||
const index = row.children.findIndex(c => allPaneIds(c).includes(paneId))
|
||||
|
||||
return child ? rootChildSide(child, id => panes.find(p => p.id === id)) : null
|
||||
const mainIndices = row.children.flatMap((child, i) =>
|
||||
allPaneIds(child).some(
|
||||
id => (panes.find(p => p.id === id)?.data as { placement?: string } | undefined)?.placement === 'main'
|
||||
)
|
||||
? [i]
|
||||
: []
|
||||
)
|
||||
|
||||
if (index < 0 || mainIndices.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return index < mainIndices[0] ? 'left' : index > mainIndices[mainIndices.length - 1] ? 'right' : null
|
||||
}
|
||||
|
||||
/** The closer-less Close: dismiss the pane (removed + remembered; reveal
|
||||
@@ -985,11 +996,52 @@ export function setTreeSideCollapsed(side: TreeSide, collapsed: boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Explicit side-open also recovers hide-only tabs, without fronting over Bots. */
|
||||
export function restoreHiddenTreeSideTabs(side: TreeSide): void {
|
||||
for (const paneId of [...$hiddenStripTabs.get()]) {
|
||||
if (paneRootSide(paneId) === side) {
|
||||
setStripTabHidden(paneId, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Restore minimized zones without changing their active tab (including Bots). */
|
||||
export function restoreMinimizedTreeSide(side: TreeSide): boolean {
|
||||
const tree = $layoutTree.get()
|
||||
const row = rootRow()
|
||||
|
||||
if (!tree || !row) {
|
||||
return false
|
||||
}
|
||||
|
||||
let next = tree
|
||||
|
||||
for (const child of row.children) {
|
||||
if (paneRootSide(allPaneIds(child)[0]) !== side) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const id of groupLeafIds(child)) {
|
||||
if (findGroup(next, id)?.minimized) {
|
||||
next = setGroupMinimized(next, id, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (next === tree) {
|
||||
return false
|
||||
}
|
||||
|
||||
commit(next)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Does the layout have a collapsible root side of `side`? ⌘J's normal target is
|
||||
* the right sidebar; a layout without one (e.g. a terminal-on-bottom preset)
|
||||
* lets callers fall back to the terminal so ⌘J is never a dead key. Semantic —
|
||||
* reuses `rootChildSide`, so it tracks a ⌘\ flip / drag like the toggles do.
|
||||
* lets callers fall back to the terminal so ⌘J is never a dead key. Tracks
|
||||
* physical position through a ⌘\ flip / drag, just like the toggles.
|
||||
*/
|
||||
export function layoutHasRootSide(side: TreeSide): boolean {
|
||||
const row = rootRow()
|
||||
@@ -998,9 +1050,7 @@ export function layoutHasRootSide(side: TreeSide): boolean {
|
||||
return false
|
||||
}
|
||||
|
||||
const panes = registry.getArea('panes')
|
||||
|
||||
return row.children.some(child => rootChildSide(child, id => panes.find(p => p.id === id)) === side)
|
||||
return row.children.some(child => paneRootSide(allPaneIds(child)[0]) === side)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1049,33 +1099,9 @@ export function bindTreeSideVisibility(
|
||||
$open.listen(open => setTreeSideCollapsed(side, !open))
|
||||
}
|
||||
|
||||
/** The chrome toggle owning `paneId`'s root-row column — SEMANTIC, matching
|
||||
* the renderer's `rootChildSide`: ⌘B ⇔ the sessions column (left-placement
|
||||
* panes) wherever it sits, ⌘J ⇔ the other side columns. Null for the main
|
||||
* column (never side-collapsed). */
|
||||
/** The physical column's chrome toggle; main columns never side-collapse. */
|
||||
export function treeSideOfPane(paneId: string): TreeSide | null {
|
||||
const row = rootRow()
|
||||
|
||||
if (!row) {
|
||||
return null
|
||||
}
|
||||
|
||||
const child = row.children.find(node => allPaneIds(node).includes(paneId))
|
||||
|
||||
if (!child) {
|
||||
return null
|
||||
}
|
||||
|
||||
const placementOf = (id: string) =>
|
||||
(registry.getArea('panes').find(c => c.id === id)?.data as { placement?: string } | undefined)?.placement
|
||||
|
||||
const placements = allPaneIds(child).map(placementOf)
|
||||
|
||||
if (placements.includes('main')) {
|
||||
return null
|
||||
}
|
||||
|
||||
return placements.includes('left') ? 'left' : 'right'
|
||||
return paneRootSide(paneId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,13 +2,17 @@ import { atom, computed, type ReadableAtom, type WritableAtom } from 'nanostores
|
||||
|
||||
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from '@/app/layout-constants'
|
||||
import { PANE_TOGGLE_REVEAL_EVENT } from '@/components/pane-shell'
|
||||
import { isPaneVisible, revealTreePane } from '@/components/pane-shell/tree/store'
|
||||
import {
|
||||
restoreHiddenTreeSideTabs,
|
||||
restoreMinimizedTreeSide,
|
||||
setTreeSideCollapsed
|
||||
} from '@/components/pane-shell/tree/store'
|
||||
import { matchesQuery } from '@/hooks/use-media-query'
|
||||
import { connectionScopedAtom } from '@/lib/connection-scoped'
|
||||
import { type Codec, Codecs, persistentAtom } from '@/lib/persisted'
|
||||
import { arraysEqual, insertUniqueId, readKey } from '@/lib/storage'
|
||||
|
||||
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride, togglePane } from './panes'
|
||||
import { $paneStates, ensurePaneRegistered, setPaneOpen, setPaneWidthOverride } from './panes'
|
||||
import { $showAllProfiles, setShowAllProfiles } from './profile'
|
||||
import type { PullRequestBucket } from './pull-requests'
|
||||
import type { SessionStatusBucket } from './session-dot-state'
|
||||
@@ -529,12 +533,21 @@ function revealNarrowPane(id: string, mode: 'close' | 'open' | 'toggle'): boolea
|
||||
|
||||
export function setSidebarOpen(open: boolean) {
|
||||
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
|
||||
setTreeSideCollapsed('left', !open)
|
||||
|
||||
if (open) {
|
||||
restoreMinimizedTreeSide('left')
|
||||
restoreHiddenTreeSideTabs('left')
|
||||
}
|
||||
|
||||
revealNarrowPane(CHAT_SIDEBAR_PANE_ID, open ? 'open' : 'close')
|
||||
}
|
||||
|
||||
export function toggleSidebarOpen() {
|
||||
if (!revealNarrowPane(CHAT_SIDEBAR_PANE_ID, 'toggle')) {
|
||||
togglePane(CHAT_SIDEBAR_PANE_ID)
|
||||
const open = restoreMinimizedTreeSide('left') || !$sidebarOpen.get()
|
||||
setPaneOpen(CHAT_SIDEBAR_PANE_ID, open)
|
||||
setTreeSideCollapsed('left', !open)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,23 +556,20 @@ export function toggleFileBrowserOpen() {
|
||||
return
|
||||
}
|
||||
|
||||
// Ask the TREE, not the pane's boolean. `$fileBrowserOpen` stays true while
|
||||
// the tree pane sits behind a sibling tab in the shared right column (the
|
||||
// preview rail, the diff) or inside a minimized zone, so ⌘J spent its press
|
||||
// re-asserting a value it already held and read as a dead key. Only fold the
|
||||
// side when the tree is genuinely the thing on screen; otherwise bring it
|
||||
// forward through the reveal path, which fronts and un-minimizes.
|
||||
if (!isPaneVisible(FILES_PANE_ID) && $fileBrowserOpen.get()) {
|
||||
revealTreePane(FILES_PANE_ID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
togglePane(FILE_BROWSER_PANE_ID)
|
||||
const open = restoreMinimizedTreeSide('right') || !$fileBrowserOpen.get()
|
||||
setPaneOpen(FILE_BROWSER_PANE_ID, open)
|
||||
setTreeSideCollapsed('right', !open)
|
||||
}
|
||||
|
||||
export function setFileBrowserOpen(open: boolean) {
|
||||
setPaneOpen(FILE_BROWSER_PANE_ID, open)
|
||||
setTreeSideCollapsed('right', !open)
|
||||
|
||||
if (open) {
|
||||
restoreMinimizedTreeSide('right')
|
||||
restoreHiddenTreeSideTabs('right')
|
||||
}
|
||||
|
||||
revealNarrowPane(FILE_BROWSER_PANE_ID, open ? 'open' : 'close')
|
||||
}
|
||||
|
||||
|
||||
@@ -61,4 +61,111 @@ describe('sidebar collapse persistence', () => {
|
||||
s2.bind()
|
||||
expect(s2.leftCollapsed()).toBe(true)
|
||||
})
|
||||
|
||||
it('explicit open restores hidden strip tabs and minimized groups only on that physical side', async () => {
|
||||
for (const flipped of [false, true]) {
|
||||
window.localStorage.clear()
|
||||
reload()
|
||||
const { layout, tree, bind } = await loadStores()
|
||||
const { findGroup, group, split } = await import('@/components/pane-shell/tree/model')
|
||||
const { registry } = await import('@/contrib/registry')
|
||||
|
||||
const disposers = [
|
||||
registry.register({ area: 'panes', id: 'sessions', data: { placement: 'left' } }),
|
||||
registry.register({ area: 'panes', id: 'bots', data: { placement: 'left' } }),
|
||||
registry.register({ area: 'panes', id: 'workspace', data: { placement: 'main' } }),
|
||||
registry.register({ area: 'panes', id: 'files', data: { placement: 'right' } }),
|
||||
registry.register({ area: 'panes', id: 'review', data: { placement: 'right' } })
|
||||
]
|
||||
|
||||
try {
|
||||
const sidebar = group(['sessions', 'bots'], { active: 'bots', id: 'sidebar' })
|
||||
const main = group(['workspace'])
|
||||
const other = group(['files', 'review'], { id: 'other' })
|
||||
tree.declareDefaultTree(split('row', flipped ? [other, main, sidebar] : [sidebar, main, other]))
|
||||
bind()
|
||||
tree.bindTreeSideVisibility('right', layout.$fileBrowserOpen, layout.setFileBrowserOpen)
|
||||
layout.setFileBrowserOpen(true)
|
||||
tree.setStripTabHidden('sessions', true)
|
||||
tree.setStripTabHidden('review', true)
|
||||
tree.setTreeGroupMinimized('sidebar', true)
|
||||
tree.setTreeGroupMinimized('other', true)
|
||||
const open = flipped ? layout.setFileBrowserOpen : layout.setSidebarOpen
|
||||
|
||||
open(true) // already true: must not depend on a nanostores notification
|
||||
expect(tree.isStripTabHidden('sessions')).toBe(false)
|
||||
expect(tree.$hiddenTreePanes.get().has('sessions')).toBe(false)
|
||||
expect(tree.isStripTabHidden('review')).toBe(true)
|
||||
expect(findGroup(tree.$layoutTree.get()!, 'sidebar')).toMatchObject({ active: 'bots', minimized: false })
|
||||
expect(findGroup(tree.$layoutTree.get()!, 'other')?.minimized).toBe(true)
|
||||
expect(JSON.parse(window.localStorage.getItem('hermes.desktop.hiddenStripTabs.v1')!)).toEqual(['review'])
|
||||
const restored = tree.$layoutTree.get()
|
||||
open(true)
|
||||
expect(tree.$layoutTree.get()).toBe(restored)
|
||||
tree.setStripTabHidden('sessions', true)
|
||||
const toggle = flipped ? layout.toggleFileBrowserOpen : layout.toggleSidebarOpen
|
||||
toggle()
|
||||
toggle()
|
||||
expect(tree.isStripTabHidden('sessions')).toBe(true) // ordinary toggles preserve the tab choice
|
||||
} finally {
|
||||
disposers.forEach(dispose => dispose())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('recovers the minimized physical side after reload without changing its active tab', async () => {
|
||||
for (const flipped of [false, true]) {
|
||||
window.localStorage.clear()
|
||||
reload()
|
||||
const s1 = await loadStores()
|
||||
const { group, split } = await import('@/components/pane-shell/tree/model')
|
||||
const sidebar = group(['sessions', 'bots'], { active: 'bots', id: 'sidebar' })
|
||||
const main = group(['workspace'])
|
||||
const files = group(['files'], { id: 'files-zone' })
|
||||
s1.tree.declareDefaultTree(split('row', flipped ? [files, main, sidebar] : [sidebar, main, files]))
|
||||
s1.tree.setTreeGroupMinimized(sidebar.id, true)
|
||||
s1.layout.setFileBrowserOpen(true)
|
||||
|
||||
reload()
|
||||
const { layout, tree, bind } = await loadStores()
|
||||
const { findGroup } = await import('@/components/pane-shell/tree/model')
|
||||
const { registry } = await import('@/contrib/registry')
|
||||
|
||||
const disposers = [
|
||||
registry.register({ area: 'panes', id: 'sessions', data: { placement: 'left' } }),
|
||||
registry.register({ area: 'panes', id: 'bots', data: { placement: 'left' } }),
|
||||
registry.register({ area: 'panes', id: 'workspace', data: { placement: 'main' } }),
|
||||
registry.register({ area: 'panes', id: 'files', data: { placement: 'right' } })
|
||||
]
|
||||
|
||||
try {
|
||||
bind()
|
||||
tree.bindTreeSideVisibility('right', layout.$fileBrowserOpen, layout.setFileBrowserOpen)
|
||||
const side = flipped ? 'right' : 'left'
|
||||
const toggle = flipped ? layout.toggleFileBrowserOpen : layout.toggleSidebarOpen
|
||||
const $open = flipped ? layout.$fileBrowserOpen : layout.$sidebarOpen
|
||||
const otherToggle = flipped ? layout.toggleSidebarOpen : layout.toggleFileBrowserOpen
|
||||
|
||||
// Boot respects minimize; the OTHER physical button must not restore it.
|
||||
expect(findGroup(tree.$layoutTree.get()!, sidebar.id)?.minimized).toBe(true)
|
||||
otherToggle()
|
||||
expect(tree.$collapsedTreeSides.get().has(flipped ? 'left' : 'right')).toBe(true)
|
||||
expect(findGroup(tree.$layoutTree.get()!, sidebar.id)?.minimized).toBe(true)
|
||||
|
||||
toggle()
|
||||
expect($open.get()).toBe(true)
|
||||
expect(tree.$collapsedTreeSides.get().has(side)).toBe(false)
|
||||
expect(findGroup(tree.$layoutTree.get()!, sidebar.id)).toMatchObject({ active: 'bots', minimized: false })
|
||||
toggle()
|
||||
expect($open.get()).toBe(false)
|
||||
expect(tree.$collapsedTreeSides.get().has(side)).toBe(true)
|
||||
toggle()
|
||||
expect($open.get()).toBe(true)
|
||||
expect(tree.$collapsedTreeSides.get().has(side)).toBe(false)
|
||||
expect(findGroup(tree.$layoutTree.get()!, sidebar.id)?.active).toBe('bots')
|
||||
} finally {
|
||||
disposers.forEach(dispose => dispose())
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user