// 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') }) }) })