Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,23 @@
* Contact: alex@kuleshov.tech
*/

import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import App from './App'
import { useEditorStore } from './stores/editor'
import { useSpaceStore } from './stores/space'
import { useTreeStore } from './stores/tree'
import { useUiStore } from './stores/ui'
import { useViewerStore } from './stores/viewer'

vi.mock('./lib/api', () => ({
setCurrentSpaceSlug: vi.fn(),
listSpaces: vi.fn(async () => [
{ id: 's1', slug: 'default', name: 'Default', createdAt: '2026-01-01T00:00:00Z' },
{ id: 's2', slug: 'docs', name: 'Docs', createdAt: '2026-01-01T00:00:00Z' },
]),
getAuthConfig: vi.fn(async () => ({
authDisabled: true,
publicAccess: true,
Expand Down Expand Up @@ -130,6 +136,11 @@ describe('App', () => {
loading: false,
error: null,
})
useSpaceStore.setState({
spaces: [],
activeSlug: 'default',
loaded: false,
})
useUiStore.setState({
isDark: false,
sidebarVisible: true,
Expand All @@ -148,7 +159,20 @@ describe('App', () => {
</MemoryRouter>,
)

expect(await screen.findByTitle('Image version sha-1234567')).toHaveTextContent('sha-1234567')
const sidebar = await screen.findByTestId('sidebar')
expect(within(sidebar).getByTitle('Image version sha-1234567')).toHaveTextContent('sha-1234567')
expect(within(screen.getByRole('banner')).queryByTitle('Image version sha-1234567')).not.toBeInTheDocument()
})

it('shows a standalone space switcher when there is no account menu', async () => {
render(
<MemoryRouter initialEntries={['/']}>
<App />
</MemoryRouter>,
)

expect(await screen.findByRole('button', { name: 'Switch space, current space Default' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Account menu/ })).not.toBeInTheDocument()
})

it('renders the shell and loads the root page without crashing', async () => {
Expand Down
8 changes: 3 additions & 5 deletions frontend/src/features/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Link } from 'react-router-dom'
import { Sidebar } from '../sidebar/Sidebar'
import { Toolbar } from '../toolbar/Toolbar'
import type { WikiNodeKind, WikiTreeNode } from '../../types'
import { SpaceSwitcher } from './SpaceSwitcher'
import { UserMenu } from './UserMenu'

interface AppLayoutProps {
Expand Down Expand Up @@ -133,11 +134,7 @@ export function AppLayout({
Import
</Link>
) : null}
{displayImageVersion ? (
<span className="app-layout__image-version hidden xl:inline-flex" title={`Image version ${displayImageVersion}`}>
{displayImageVersion}
</span>
) : null}
{currentUsername ? null : <SpaceSwitcher />}
{currentUsername ? (
<UserMenu
username={currentUsername}
Expand Down Expand Up @@ -182,6 +179,7 @@ export function AppLayout({
onExpandAll={onExpandAll}
onCollapseAll={onCollapseAll}
onOpenSearch={onOpenSearch}
imageVersion={displayImageVersion}
/>
</div>
</>
Expand Down
83 changes: 83 additions & 0 deletions frontend/src/features/layout/SpaceSwitcher.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/*
* Copyright 2026 Aleksei Kuleshov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contact: alex@kuleshov.tech
*/

import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { useSpaceStore } from '../../stores/space'
import { useTreeStore } from '../../stores/tree'
import type { Space } from '../../types'
import { SpaceSwitcher } from './SpaceSwitcher'

const spaces: Space[] = [
{ id: 's1', slug: 'default', name: 'Default', createdAt: '2026-01-01T00:00:00Z' },
{ id: 's2', slug: 'product', name: 'Product', createdAt: '2026-01-01T00:00:00Z' },
]

function LocationProbe() {
const location = useLocation()
return <div data-testid="location">{location.pathname}</div>
}

function renderSwitcher() {
return render(
<MemoryRouter initialEntries={['/docs/page']}>
<SpaceSwitcher />
<Routes>
<Route path="*" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
)
}

describe('SpaceSwitcher', () => {
beforeEach(() => {
useSpaceStore.setState({
spaces,
activeSlug: 'default',
loaded: true,
})
useTreeStore.setState({ reloadTree: vi.fn(async () => undefined) })
})

it('switches the active space, reloads the tree, and navigates home', async () => {
const reloadTree = vi.fn(async () => undefined)
useTreeStore.setState({ reloadTree })
const user = userEvent.setup()

renderSwitcher()
await user.click(screen.getByRole('button', { name: 'Switch space, current space Default' }))
await user.click(await screen.findByRole('menuitemradio', { name: 'Product' }))

await waitFor(() => {
expect(useSpaceStore.getState().activeSlug).toBe('product')
})
expect(reloadTree).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('location')).toHaveTextContent('/')
})

it('stays hidden when there is only one space', () => {
useSpaceStore.setState({ spaces: [spaces[0]], activeSlug: 'default', loaded: true })

renderSwitcher()

expect(screen.queryByRole('button', { name: /Switch space/i })).not.toBeInTheDocument()
})
})
89 changes: 89 additions & 0 deletions frontend/src/features/layout/SpaceSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* Copyright 2026 Aleksei Kuleshov
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
* Contact: alex@kuleshov.tech
*/

import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { Check, ChevronDown, Layers } from 'lucide-react'
import { useNavigate } from 'react-router-dom'

import { useSpaceStore } from '../../stores/space'
import { useTreeStore } from '../../stores/tree'

export function SpaceSwitcher() {
const spaces = useSpaceStore((state) => state.spaces)
const activeSlug = useSpaceStore((state) => state.activeSlug)
const setActiveSlug = useSpaceStore((state) => state.setActiveSlug)
const reloadTree = useTreeStore((state) => state.reloadTree)
const navigate = useNavigate()

if (spaces.length <= 1) {
return null
}

const activeSpace = spaces.find((space) => space.slug === activeSlug) ?? spaces[0]
const activeName = activeSpace?.name ?? activeSlug

const handleSpaceChange = (slug: string) => {
if (!slug || slug === activeSlug) {
return
}
setActiveSlug(slug)
navigate('/')
void reloadTree()
}

return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button
type="button"
className="space-switcher__trigger action-button-secondary"
aria-label={`Switch space, current space ${activeName}`}
title={`Switch space: ${activeName}`}
>
<Layers size={16} aria-hidden="true" />
<span className="space-switcher__trigger-label">{activeName}</span>
<ChevronDown size={14} className="space-switcher__trigger-chevron" aria-hidden="true" />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content align="end" sideOffset={6} className="space-switcher__content">
<DropdownMenu.Label className="space-switcher__label">Switch space</DropdownMenu.Label>
<DropdownMenu.RadioGroup
aria-label="Switch space"
value={activeSlug}
onValueChange={handleSpaceChange}
>
{spaces.map((space) => (
<DropdownMenu.RadioItem
key={space.id}
value={space.slug}
className="space-switcher__item space-switcher__item--radio"
>
<Layers size={14} aria-hidden="true" />
<span className="space-switcher__item-label">{space.name}</span>
<DropdownMenu.ItemIndicator className="space-switcher__item-indicator">
<Check size={14} aria-hidden="true" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}
9 changes: 9 additions & 0 deletions frontend/src/features/sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ interface SidebarProps {
onExpandAll: () => void
onCollapseAll: () => void
onOpenSearch: () => void
imageVersion?: string | null
}

export function Sidebar({
Expand All @@ -61,6 +62,7 @@ export function Sidebar({
onExpandAll,
onCollapseAll,
onOpenSearch,
imageVersion,
}: SidebarProps) {
const [activeTab, setActiveTab] = useState<'tree' | 'search'>('tree')

Expand Down Expand Up @@ -170,6 +172,13 @@ export function Sidebar({
/>
)}
</div>
{imageVersion ? (
<div className="sidebar__footer">
<span className="sidebar__image-version" title={`Image version ${imageVersion}`}>
{imageVersion}
</span>
</div>
) : null}
</div>
</aside>
)
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/features/viewer/PageViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'sonner'
import { ensurePage } from '../../lib/api'
import { normalizeWikiPath } from '../../lib/paths'
import { useSpaceStore } from '../../stores/space'
import { useViewerStore } from '../../stores/viewer'

export function PageViewer() {
Expand All @@ -39,6 +40,7 @@ export function PageViewer() {
const error = useViewerStore((state) => state.error)
const loading = useViewerStore((state) => state.loading)
const loadPageData = useViewerStore((state) => state.loadPageData)
const activeSpaceSlug = useSpaceStore((state) => state.activeSlug)
const setActiveNodeId = useTreeStore((state) => state.setActiveNodeId)
const openAncestorsForPath = useTreeStore((state) => state.openAncestorsForPath)
const getPageByPath = useTreeStore((state) => state.getPageByPath)
Expand All @@ -55,7 +57,7 @@ export function PageViewer() {

useEffect(() => {
void loadPageData(currentPath)
}, [currentPath, loadPageData])
}, [activeSpaceSlug, currentPath, loadPageData])

useEffect(() => {
const treeNode = getPageByPath(currentPath)
Expand Down
52 changes: 46 additions & 6 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@
}

.sidebar__inner {
@apply block h-full w-full bg-sidebar;
@apply flex h-full w-full flex-col bg-sidebar;
}

.sidebar__tabs {
Expand All @@ -166,7 +166,15 @@
}

.sidebar__content {
@apply h-[calc(100%-48px)] w-full;
@apply min-h-0 flex-1 w-full;
}

.sidebar__footer {
@apply border-t border-surface-border px-3 py-2;
}

.sidebar__image-version {
@apply inline-flex max-w-full truncate rounded-lg border border-white/10 bg-white/5 px-2 py-1 text-xs font-medium text-sidebar-foreground/70;
}

.app-layout__header {
Expand Down Expand Up @@ -201,10 +209,6 @@
@apply ml-auto flex min-h-full items-center gap-1 md:gap-2;
}

.app-layout__image-version {
@apply max-w-28 truncate rounded-full border border-surface-border bg-background/50 px-2 py-1 text-xs font-medium text-muted;
}

.app-layout__header-spacer {
@apply h-16 w-full md:h-[68px];
}
Expand Down Expand Up @@ -273,6 +277,42 @@
@apply ml-auto inline-flex items-center justify-center text-accent;
}

.space-switcher__trigger {
@apply max-w-[42vw] rounded-lg px-2 sm:max-w-56 sm:px-3;
}

.space-switcher__trigger-label {
@apply hidden min-w-0 truncate sm:inline;
}

.space-switcher__trigger-chevron {
@apply hidden sm:inline;
}

.space-switcher__content {
@apply z-50 min-w-56 max-w-[calc(100vw-1rem)] rounded-lg border border-surface-border bg-surface p-2 shadow-2xl outline-none;
}

.space-switcher__label {
@apply px-3 pt-2 pb-1 text-xs font-semibold uppercase tracking-wide text-muted;
}

.space-switcher__item {
@apply flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground outline-none transition hover:bg-surface-alt focus:bg-surface-alt data-[highlighted]:bg-surface-alt;
}

.space-switcher__item--radio {
@apply pr-8;
}

.space-switcher__item-label {
@apply truncate;
}

.space-switcher__item-indicator {
@apply ml-auto inline-flex items-center justify-center text-accent;
}

.app-layout__chromeless-main {
@apply flex min-h-screen flex-col items-stretch overflow-auto;
}
Expand Down
Loading