// src/layout/__tests__/SidebarViewContainer.test.tsx
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { render, screen, cleanup, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import { createElement } from 'react'
import { Workspace } from '../../plugin/workspace'
import { ItemView, type ReactItemView } from '../../plugin/views/item-view'
import { SidebarViewContainer } from '../SidebarViewContainer'
import { savePersistedSidebarLeaves } from '../sidebar-leaf-persistence'
import { ReamsEvents } from '../../plugin/reams/events'
import type { App } from '../../plugin/app'
import { setTypesRecordsEnabled, TYPES_RECORDS_SETTING_KEY } from '../../types-records/feature-setting'
import { TYPES_BROWSER_VIEW_TYPE } from '../../plugins/types-browser/types-browser-view'
import { openTestVaultPreferences } from '../../services/__tests__/vault-state-test-helpers'
import { writeVaultPreference, type VaultPreferences } from '../../services/vault-preferences'
class TestViewA extends ItemView {
getViewType() { return 'view-a' }
getDisplayText() { return 'View A' }
getIcon() { return 'folder' }
onOpen() {
this.contentEl.textContent = 'View A Content'
return Promise.resolve()
}
}
class TestViewB extends ItemView {
getViewType() { return 'view-b' }
getDisplayText() { return 'View B' }
getIcon() { return 'search' }
onOpen() {
this.contentEl.textContent = 'View B Content'
return Promise.resolve()
}
}
class TestTypesView extends ItemView {
getViewType() { return TYPES_BROWSER_VIEW_TYPE }
getDisplayText() { return 'Types' }
onOpen() { return Promise.resolve() }
}
/** ReactItemView for testing the inline React rendering path */
class TestReactView extends ItemView implements ReactItemView {
getViewType() { return 'react-view' }
getDisplayText() { return 'React View' }
getIcon() { return 'calendar' }
getReactComponent(): ReactNode {
return createElement('div', { 'data-testid': 'react-view-content' }, 'React View Content')
}
}
/** View with unknown icon to test fallback */
class TestViewUnknownIcon extends ItemView {
getViewType() { return 'view-unknown-icon' }
getDisplayText() { return 'Mystery View' }
getIcon() { return 'definitely-not-a-real-icon-xyz' }
onOpen() {
this.contentEl.textContent = 'Mystery Content'
return Promise.resolve()
}
}
/** Helper to get all sidebar-view-leaf elements and their data-visible attrs */
function getLeafVisibility(container: HTMLElement) {
const leaves = container.querySelectorAll('.sidebar-view-leaf')
return Array.from(leaves).map(el => ({
visible: el.getAttribute('data-visible'),
}))
}
/**
* Create a minimal App mock for testing components that need app.reamsEvents
*/
function createAppMock(ws: Workspace): App {
const reamsEvents = new ReamsEvents()
const app = {
workspace: ws,
reamsEvents,
} as unknown as App
ws.app = app
return app
}
describe('SidebarViewContainer', () => {
let workspace: Workspace
let app: App
let preferences: VaultPreferences
beforeEach(async () => {
preferences = await openTestVaultPreferences()
workspace = new Workspace(document.createElement('div'))
app = createAppMock(workspace)
localStorage.clear()
})
afterEach(() => {
cleanup()
preferences.close()
})
it('keeps sidebar DOM and keyboard order aligned when a view requests a trailing position', async () => {
class TrailingView extends TestReactView { readonly sidebarOrder = 1 }
workspace.registerViewType('react-view', leaf => new TrailingView(leaf))
workspace.registerViewType('view-a', leaf => new TestViewA(leaf))
await workspace.getRightLeaf(true).openView('react-view')
await workspace.getRightLeaf(true).openView('view-a')
render()
const tabs = screen.getAllByRole('tab')
expect(tabs.map(tab => tab.getAttribute('aria-label'))).toEqual(['View A', 'React View'])
tabs[0].focus()
await userEvent.setup().tab()
expect(tabs[1]).toHaveFocus()
})
describe('left panel', () => {
it('hides a restored Types tab while Types are off and shows it when enabled', async () => {
workspace.registerViewType('view-a', leaf => new TestViewA(leaf))
workspace.registerViewType(TYPES_BROWSER_VIEW_TYPE, leaf => new TestTypesView(leaf))
await workspace.getLeftLeaf(true).openView('view-a')
const typesLeaf = workspace.getLeftLeaf(true)
await typesLeaf.openView(TYPES_BROWSER_VIEW_TYPE)
render()
expect(screen.queryByRole('tab', { name: 'Types' })).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'View A' })).toBeInTheDocument()
act(() => setTypesRecordsEnabled(true))
expect(screen.getByRole('tab', { name: 'Types' })).toBeInTheDocument()
act(() => {
writeVaultPreference(TYPES_RECORDS_SETTING_KEY, 'false')
})
expect(screen.queryByRole('tab', { name: 'Types' })).not.toBeInTheDocument()
expect(workspace.getLeavesOfType(TYPES_BROWSER_VIEW_TYPE)).toHaveLength(1)
})
it('shows placeholder when no views are registered', () => {
render()
expect(screen.getByText('No views available')).toBeInTheDocument()
})
it('replaces the empty-view message with real panel chrome while views load', async () => {
savePersistedSidebarLeaves([{ side: 'left', viewType: 'view-a' }])
const { container, rerender } = render()
expect(screen.getByRole('status', { name: 'Loading sidebar views' })).toHaveAttribute('aria-busy', 'true')
expect(screen.queryByText('No views available')).not.toBeInTheDocument()
expect(container.querySelector('.sidebar-view-tabs')).toBeInTheDocument()
expect(container.querySelector('.sidebar-view-header')).toBeInTheDocument()
expect(container.querySelectorAll('.file-tree-item')).toHaveLength(8)
expect(container.querySelectorAll('.sidebar-loading__tab')).toHaveLength(1)
expect([...container.querySelectorAll('.sidebar-loading__tab')].every(tab => tab.childElementCount === 0)).toBe(true)
expect(container.querySelector('.sidebar-loading__icon')).not.toBeInTheDocument()
expect(container.querySelector('.file-tree-item__chevron')).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
workspace.registerViewType('view-a', leaf => new TestViewA(leaf))
await act(async () => { await workspace.getLeftLeaf(true).openView('view-a') })
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'View A' })).toBeInTheDocument()
await act(async () => { workspace.getLeftLeaves()[0].detach() })
rerender()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('No views available')).toBeInTheDocument()
})
it('renders tab bar even with a single view', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('view-a')
render()
// Tab bar should be present even for a single view
expect(screen.getByRole('tablist')).toBeInTheDocument()
// The single tab should be active
const tab = screen.getByRole('tab', { name: 'View A' })
expect(tab).toHaveAttribute('aria-selected', 'true')
// View content should render
expect(screen.getByText('View A Content')).toBeInTheDocument()
})
it('renders vertical tab bar with aria-orientation', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
render()
const tablist = screen.getByRole('tablist')
expect(tablist).toHaveAttribute('aria-orientation', 'vertical')
})
it('renders icon SVG in tab buttons for known icons', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('view-a')
render()
// Tab should contain an SVG (from getIcon('folder'))
const tab = screen.getByRole('tab', { name: 'View A' })
const svg = tab.querySelector('svg')
expect(svg).not.toBeNull()
})
it('renders text fallback for unknown icon names', async () => {
workspace.registerViewType('view-unknown-icon', (leaf) => new TestViewUnknownIcon(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('view-unknown-icon')
render()
const tab = screen.getByRole('tab', { name: 'Mystery View' })
// Should show fallback letter 'M' (first char of 'Mystery View')
const fallback = tab.querySelector('.tab-icon-fallback')
expect(fallback).not.toBeNull()
expect(fallback!.textContent).toBe('M')
})
it('shows title tooltip on tab buttons', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
render()
const tabA = screen.getByRole('tab', { name: 'View A' })
expect(tabA).toHaveAttribute('title', 'View A')
const tabB = screen.getByRole('tab', { name: 'View B' })
expect(tabB).toHaveAttribute('title', 'View B')
})
it('shows tab aria-label as view display text', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
render()
expect(screen.getByRole('tab', { name: 'View A' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: 'View B' })).toBeInTheDocument()
})
it('switches visible view when clicking a tab', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
const user = userEvent.setup()
const { container } = render()
// First tab should be active by default
let visibility = getLeafVisibility(container)
expect(visibility[0].visible).toBe('true')
expect(visibility[1].visible).toBe('false')
// Click the second tab
await user.click(screen.getByRole('tab', { name: 'View B' }))
visibility = getLeafVisibility(container)
expect(visibility[0].visible).toBe('false')
expect(visibility[1].visible).toBe('true')
})
it('updates when views are dynamically added', async () => {
render()
expect(screen.getByText('No views available')).toBeInTheDocument()
await act(async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('view-a')
})
expect(screen.getByText('View A Content')).toBeInTheDocument()
})
it('shows placeholder when all views are removed', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('view-a')
render()
expect(screen.getByText('View A Content')).toBeInTheDocument()
act(() => {
workspace.unregisterViewType('view-a')
})
expect(screen.getByText('No views available')).toBeInTheDocument()
})
it('renders ReactItemView inline in the React tree', async () => {
workspace.registerViewType('react-view', (leaf) => new TestReactView(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('react-view')
render()
expect(screen.getByTestId('react-view-content')).toBeInTheDocument()
expect(screen.getByText('React View Content')).toBeInTheDocument()
})
it('persists active tab to localStorage by view type', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
const user = userEvent.setup()
render()
await user.click(screen.getByRole('tab', { name: 'View B' }))
const stored = JSON.parse(localStorage.getItem('reams:sidebar-leaves') || '{}')
expect(stored.version).toBe(2)
expect(stored.activeViewTypes?.left).toBe('view-b')
})
it('restores active tab from localStorage by view type', async () => {
localStorage.setItem('reams:sidebar-leaves', JSON.stringify({
version: 2,
leaves: [],
activeViewTypes: { left: 'view-b' },
}))
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
let container: HTMLElement
// Use act() to flush all effects (bootstrap + subscription) in one batch
await act(async () => {
const result = render()
container = result.container
})
const tabB = screen.getByRole('tab', { name: 'View B' })
expect(tabB).toHaveAttribute('aria-selected', 'true')
const tabA = screen.getByRole('tab', { name: 'View A' })
expect(tabA).toHaveAttribute('aria-selected', 'false')
const visibility = getLeafVisibility(container!)
expect(visibility[0].visible).toBe('false')
expect(visibility[1].visible).toBe('true')
})
it('auto-selects next tab when active view is removed', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
const user = userEvent.setup()
const { container } = render()
// Select view-a as active
await user.click(screen.getByRole('tab', { name: 'View A' }))
// Remove view-a (simulates plugin disable)
act(() => {
workspace.unregisterViewType('view-a')
})
// view-b should now be active
const tabB = screen.getByRole('tab', { name: 'View B' })
expect(tabB).toHaveAttribute('aria-selected', 'true')
const visibility = getLeafVisibility(container)
expect(visibility[0].visible).toBe('true')
})
it('persists the auto-selected tab to localStorage when active view is removed', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
const user = userEvent.setup()
render()
// Select view-a as active (persists 'view-a')
await user.click(screen.getByRole('tab', { name: 'View A' }))
let stored = JSON.parse(localStorage.getItem('reams:sidebar-leaves') || '{}')
expect(stored.activeViewTypes?.left).toBe('view-a')
// Remove view-a — should auto-select view-b and persist it
act(() => {
workspace.unregisterViewType('view-a')
})
stored = JSON.parse(localStorage.getItem('reams:sidebar-leaves') || '{}')
expect(stored.activeViewTypes?.left).toBe('view-b')
})
it('falls back to first tab when persisted view type no longer exists', async () => {
// Persist a view type that won't exist
localStorage.setItem('reams:sidebar-leaves', JSON.stringify({
version: 2,
leaves: [],
activeViewTypes: { left: 'nonexistent' },
}))
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
render()
// Should fall back to first tab
const tabA = screen.getByRole('tab', { name: 'View A' })
expect(tabA).toHaveAttribute('aria-selected', 'true')
})
it('ignores legacy sidebar and panel selection state', async () => {
// Legacy state must not override the first current leaf's selected view.
localStorage.setItem('reams:sidebar-leaves', JSON.stringify({
version: 1,
leaves: [{ viewType: 'view-b', side: 'left' }],
}))
localStorage.setItem('reams:panel-views', JSON.stringify({
leftActiveViewType: 'view-b',
}))
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
await act(async () => {
render()
})
const tabA = screen.getByRole('tab', { name: 'View A' })
expect(tabA).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('tab', { name: 'View B' })).toHaveAttribute('aria-selected', 'false')
// Legacy storage is inert and preserved for any other legacy consumer.
expect(JSON.parse(localStorage.getItem('reams:panel-views') || '{}')).toEqual({
leftActiveViewType: 'view-b',
})
const stored = JSON.parse(localStorage.getItem('reams:sidebar-leaves') || '{}')
expect(stored.version).toBe(2)
expect(stored.activeViewTypes?.left).toBe('view-a')
})
it('shows placeholder when plugin disable removes last view', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('view-a')
render()
expect(screen.getByText('View A Content')).toBeInTheDocument()
act(() => {
workspace.unregisterViewType('view-a')
})
expect(screen.getByText('No views available')).toBeInTheDocument()
})
})
describe('right panel', () => {
it('reads from rightLeaves when side is right', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
const leaf = workspace.getRightLeaf(true)
await leaf.openView('view-a')
render()
expect(screen.getByText('View A Content')).toBeInTheDocument()
})
it('persists right panel active tab separately from left', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getRightLeaf(true)
await leafA.openView('view-a')
const leafB = workspace.getRightLeaf(true)
await leafB.openView('view-b')
const user = userEvent.setup()
render()
await user.click(screen.getByRole('tab', { name: 'View B' }))
const stored = JSON.parse(localStorage.getItem('reams:sidebar-leaves') || '{}')
expect(stored.version).toBe(2)
expect(stored.activeViewTypes?.right).toBe('view-b')
expect(stored.activeViewTypes?.left).toBeUndefined()
})
it('does not show left panel leaves when side is right', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('view-a')
render()
expect(screen.getByText('No views available')).toBeInTheDocument()
})
it('has data-side attribute for CSS targeting', async () => {
workspace.registerViewType('view-a', (leaf) => new TestViewA(leaf))
const leaf = workspace.getRightLeaf(true)
await leaf.openView('view-a')
const { container } = render()
const svc = container.querySelector('.sidebar-view-container')
expect(svc).toHaveAttribute('data-side', 'right')
})
})
describe('view-header-actions-change', () => {
// A view whose getHeaderActions output flips when its internal counter
// changes. We use this to verify that requestHeaderActionsUpdate causes
// the chrome row to re-render with the new content.
class DynamicActionsView extends ItemView implements ReactItemView {
private _label = 'initial'
setLabel(label: string) {
this._label = label
}
getViewType() {
return 'dynamic-actions'
}
getDisplayText() {
return 'Dynamic'
}
getReactComponent(): ReactNode {
return createElement('div', { 'data-testid': 'dynamic-content' }, 'body')
}
getHeaderActions(): ReactNode {
return createElement(
'button',
{ type: 'button', 'data-testid': 'dynamic-action' },
this._label,
)
}
}
it('re-renders header actions after requestHeaderActionsUpdate()', async () => {
workspace.registerViewType('dynamic-actions', (leaf) => new DynamicActionsView(leaf))
const leaf = workspace.getLeftLeaf(true)
await leaf.openView('dynamic-actions')
render()
expect(screen.getByTestId('dynamic-action').textContent).toBe('initial')
const view = leaf.view as DynamicActionsView
act(() => {
view.setLabel('updated')
view.requestHeaderActionsUpdate()
})
expect(screen.getByTestId('dynamic-action').textContent).toBe('updated')
})
it('ignores requestHeaderActionsUpdate when the firing leaf is not the visible one', async () => {
workspace.registerViewType('dynamic-actions', (leaf) => new DynamicActionsView(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('dynamic-actions')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
const user = userEvent.setup()
render()
// Activate view-b (so the dynamic view is no longer the visible one).
await user.click(screen.getByRole('tab', { name: 'View B' }))
// Calling requestHeaderActionsUpdate on the non-visible view must
// not throw and must not affect what's currently displayed.
const view = leafA.view as DynamicActionsView
expect(() =>
act(() => {
view.setLabel('hidden')
view.requestHeaderActionsUpdate()
}),
).not.toThrow()
// View B has no getHeaderActions, so no dynamic-action button is shown.
expect(screen.queryByTestId('dynamic-action')).toBeNull()
})
it('background-leaf reactivation: mutating a non-visible leaf then activating it shows the new actions', async () => {
// Two leaves on the left side. We start with leaf-A's dynamic view
// showing 'initial'. We swap to leaf-B (active), then mutate the
// background leaf-A's label and call requestHeaderActionsUpdate
// (which is a no-op for the chrome row right now because leaf-A is
// not visible). When the user later switches back to leaf-A, the
// chrome row must reflect the new label — i.e. the activeView
// identity change must trigger a getHeaderActions() recompute.
workspace.registerViewType('dynamic-actions', (leaf) => new DynamicActionsView(leaf))
workspace.registerViewType('view-b', (leaf) => new TestViewB(leaf))
const leafA = workspace.getLeftLeaf(true)
await leafA.openView('dynamic-actions')
const leafB = workspace.getLeftLeaf(true)
await leafB.openView('view-b')
const user = userEvent.setup()
render()
// Confirm leaf-A is initially active and shows 'initial'.
expect(screen.getByTestId('dynamic-action').textContent).toBe('initial')
// Switch to leaf-B → leaf-A is now background.
await user.click(screen.getByRole('tab', { name: 'View B' }))
expect(screen.queryByTestId('dynamic-action')).toBeNull()
// Mutate background leaf-A and fire requestHeaderActionsUpdate
// (which won't immediately re-render because leaf-A is hidden).
const view = leafA.view as DynamicActionsView
act(() => {
view.setLabel('background-updated')
view.requestHeaderActionsUpdate()
})
// Still no dynamic-action button (leaf-A still hidden).
expect(screen.queryByTestId('dynamic-action')).toBeNull()
// Reactivate leaf-A: its new label must surface in the chrome row.
await user.click(screen.getByRole('tab', { name: 'Dynamic' }))
expect(screen.getByTestId('dynamic-action').textContent).toBe('background-updated')
})
})
})