fix: keep restored mobile drawer closed

This commit is contained in:
Justin Skowyra
2026-09-02 23:26:04 +08:00
committed by Menghuan1918
parent 41da8cb101
commit 2d2e8534a5
4 changed files with 115 additions and 9 deletions
@@ -51,7 +51,7 @@
### 4.4 行为修正
- `state.ts` `loadState`:窄屏新会话默认 `panelOpen=false`(首开才生效;用户手动展开后照常持久化)。
- `state.ts` `loadState`:窄屏加载一律以 `panelOpen=false` 开始(包括持久化布局恢复);仅覆盖抽屉可见性,右侧/底部 pane、tab 与自由窗口状态原样恢复。宽屏仍按持久化值或新会话偏好决定展开状态。
- `service.ts` `openTab`:窄屏且 seed 带 `path`/`url` 且面板收起 → 自动展开抽屉(dedupe-focus 分支同样生效);纯类型打开不展开;宽屏行为不变。
### 4.5 样式(`sidebar.module.css`)
@@ -61,7 +61,7 @@
### 4.6 测试
- `tests/breakpoints.spec.ts`:断点边界(767 窄 / 768 宽;1024 明确为宽)。
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起(stub `window.innerWidth=390`)。
- `tests/unit.spec.ts`:`migrateBottomTabs` 4 例(并入第一 leaf 且底部清空/面板关/activePane 重指、右树为 split 时并入最左 leaf、幂等性、空底部树但 activePane 在底部树时重指)+ 窄屏新会话默认收起;`tests/state.spec.ts` 守护窄屏恢复强制收起且 pane / free-window 状态不丢,768px 宽屏仍恢复持久化展开值。
- `tests/service.spec.ts`:窄屏 auto-expand 5 例(390 < 768 仍窄)。
## 5. 边界情况
+12 -5
View File
@@ -1142,6 +1142,7 @@ function resetRequested(): boolean {
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
const reset = resetRequested()
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
if (reset) {
try {
localStorage.removeItem(`${STORAGE_PREFIX}:${sessionId}`)
@@ -1164,7 +1165,15 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
nextIdCounter = maxCounterId(parsed)
const sanitized = sanitizeState(parsed)
if (sanitized !== undefined) {
return globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
const restored = globalWidth === undefined ? sanitized : { ...sanitized, width: globalWidth }
// A narrow panel is a full-screen drawer, so every page/session load
// starts it closed even when the prior desktop/mobile state persisted
// `panelOpen: true`. Preserve the entire restored workbench (panes,
// tabs, bottom panel, and free windows); visibility is the sole
// viewport-derived override.
return viewport !== undefined && isNarrowWidth(viewport) && restored.panelOpen
? { ...restored, panelOpen: false }
: restored
}
}
} catch {
@@ -1179,10 +1188,8 @@ function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
// disabled editor type seeds nothing. On a NARROW viewport a brand-new
// session starts collapsed instead — the panel is a full-screen drawer
// there, and auto-opening it on first paint would cover the conversation
// before the user asked. Only the first seeding is affected: once the
// user expands the drawer, `panelOpen: true` persists like any other
// state.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
// before the user asked. Persisted layouts follow the same narrow-load
// visibility rule above, while their workbench contents remain intact.
const width = globalWidth ?? (viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent))
+2 -2
View File
@@ -306,8 +306,8 @@ describe('side card preferences', () => {
}
try {
const store = createSidebarStore()
// The narrow viewport keeps a fresh session collapsed for the FIRST
// seeding only (a later user expansion persists).
// The narrow viewport keeps a fresh session collapsed. Persisted
// sessions follow the same load-time visibility rule in state.spec.ts.
store.setSession('narrow-fresh')
expect(store.getSnapshot().state?.panelOpen).toBe(false)
// The width seeding still follows the window (clamped to the floor).
+99
View File
@@ -1328,6 +1328,105 @@ describe('pinned terminals (v0.17.0)', () => {
})
})
describe('narrow persisted-layout visibility (issue #162)', () => {
const persistedLayout = JSON.stringify({
panelOpen: true,
width: 420,
nextTerminal: 2,
nextBrowser: 2,
activePane: 'pane:2',
expanded: ['/workspace/src'],
splits: {
kind: 'split',
id: 'split:1',
dir: 'row',
sizes: [0.5, 0.5],
children: [
{
kind: 'leaf',
id: 'pane:1',
active: 'editor:readme',
tabs: [{ id: 'editor:readme', type: 'editor', title: 'README.md', path: '/workspace/README.md' }],
},
{
kind: 'leaf',
id: 'pane:2',
active: 'terminal:1',
tabs: [{ id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }],
},
],
},
bottomOpen: true,
bottomHeight: 300,
bottomOpenedOnce: true,
bottomSplits: {
kind: 'leaf',
id: 'pane:3',
active: 'browser:1',
tabs: [{ id: 'browser:1', type: 'browser', title: 'Docs', path: 'https://example.com' }],
},
floats: [{
id: 'float:4',
tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } },
x: 16,
y: 24,
w: 320,
h: 240,
}],
})
afterEach(() => {
const g = globalThis as Record<string, unknown>
delete g.window
delete g.localStorage
})
it.each([
{ width: 390, expectedOpen: false },
{ width: 768, expectedOpen: true },
])('restores panelOpen=$expectedOpen at $width px without changing pane or free-window state', ({ width, expectedOpen }) => {
const g = globalThis as Record<string, unknown>
g.window = {
clearTimeout: () => {},
setTimeout: () => 0,
innerWidth: width,
innerHeight: 800,
location: { search: '' },
}
g.localStorage = {
getItem: (key: string) => key === 'dsh-sidebar:v1:restored' ? persistedLayout : null,
setItem: () => {},
removeItem: () => {},
}
const store = createSidebarStore()
store.setSession('restored')
const state = store.getSnapshot().state!
expect(state.panelOpen).toBe(expectedOpen)
expect(state.activePane).toBe('pane:2')
expect(allLeaves(state.splits).map(leaf => ({
id: leaf.id,
active: leaf.active,
tabs: leaf.tabs.map(tab => tab.id),
}))).toEqual([
{ id: 'pane:1', active: 'editor:readme', tabs: ['editor:readme'] },
{ id: 'pane:2', active: 'terminal:1', tabs: ['terminal:1'] },
])
expect(state.bottomOpen).toBe(true)
expect(allLeaves(state.bottomSplits).map(leaf => ({ id: leaf.id, tabs: leaf.tabs.map(tab => tab.id) })))
.toEqual([{ id: 'pane:3', tabs: ['browser:1'] }])
expect(state.floats).toEqual([{
id: 'float:4',
tab: { id: 'notes:1', type: 'plugin:notes', title: 'Notes', meta: { draft: true } },
x: 16,
y: 24,
w: 320,
h: 240,
}])
})
})
describe('URL reset escape hatch (issue #369)', () => {
// Same browser-global stubs as the v0.12.0 block above; loadState reads
// window.location.search (reset param) and localStorage (persisted state).