fix: disambiguate sidebar type visibility by vault

This commit is contained in:
lucaronin
2026-05-24 14:05:12 +02:00
parent 5bebd1c458
commit 2fb14f4c35
14 changed files with 560 additions and 46 deletions

View File

@@ -32,6 +32,7 @@ import { useSidebarTypeInteractions } from './sidebar/useSidebarTypeInteractions
import type { AppLocale } from '../lib/i18n'
import type { FolderFileActions } from '../hooks/useFileActions'
import type { AllNotesFileVisibility } from '../utils/allNotesFileVisibility'
import { isTypeSectionVisible } from '../utils/typeVisibility'
interface SidebarProps {
entries: VaultEntry[]
@@ -45,7 +46,7 @@ interface SidebarProps {
onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void
onRenameSection?: (typeName: string, label: string) => void
onDeleteType?: (typeName: string) => void
onToggleTypeVisibility?: (typeName: string) => void
onToggleTypeVisibility?: (typeName: string, typeEntryPath?: string) => void
onSelectFavorite?: (entry: VaultEntry) => void
onReorderFavorites?: (orderedPaths: string[]) => void
views?: ViewFile[]
@@ -63,6 +64,7 @@ interface SidebarProps {
onStartRenameFolder?: (folderPath: string) => void
onCancelRenameFolder?: () => void
vaultRootPath?: string
workspaceOrder?: readonly string[]
showInbox?: boolean
inboxCount?: number
allNotesFileVisibility?: AllNotesFileVisibility
@@ -98,6 +100,7 @@ interface SidebarNavigationProps extends Pick<
| 'onStartRenameFolder'
| 'onCancelRenameFolder'
| 'vaultRootPath'
| 'workspaceOrder'
| 'showInbox'
| 'inboxCount'
| 'onCreateNewType'
@@ -116,7 +119,7 @@ interface SidebarNavigationProps extends Pick<
sectionProps: SidebarSectionProps
typeInteractions: ReturnType<typeof useSidebarTypeInteractions>
isSectionVisible: (type: string) => boolean
toggleVisibility: (type: string) => void
toggleVisibility: (type: string, typeEntryPath?: string) => void
}
type SidebarFavoritesNavigationProps = Pick<
@@ -153,6 +156,7 @@ type SidebarViewsNavigationProps = Pick<
type SidebarTypesNavigationProps = Pick<
SidebarNavigationProps,
| 'loading'
| 'entries'
| 'visibleSections'
| 'allSectionGroups'
| 'sectionIds'
@@ -165,6 +169,7 @@ type SidebarTypesNavigationProps = Pick<
| 'isSectionVisible'
| 'toggleVisibility'
| 'onCreateNewType'
| 'workspaceOrder'
| 'locale'
>
@@ -285,6 +290,7 @@ function SidebarTypesNavigation({
isSectionVisible,
toggleVisibility,
onCreateNewType,
workspaceOrder,
locale,
}: SidebarTypesNavigationProps) {
if (loading) {
@@ -300,6 +306,7 @@ function SidebarTypesNavigation({
return (
<TypesSection
entries={sectionProps.entries}
visibleSections={visibleSections}
allSectionGroups={allSectionGroups}
sectionIds={sectionIds}
@@ -314,6 +321,7 @@ function SidebarTypesNavigation({
toggleVisibility={toggleVisibility}
onCreateNewType={onCreateNewType}
customizeRef={typeInteractions.customizeRef}
workspaceOrder={workspaceOrder}
locale={locale}
/>
)
@@ -423,6 +431,7 @@ function SidebarViewAndTypeNavigation(props: SidebarNavigationProps) {
)}
<SidebarTypesNavigation
loading={props.loading}
entries={props.entries}
visibleSections={props.visibleSections}
allSectionGroups={props.allSectionGroups}
sectionIds={props.sectionIds}
@@ -435,6 +444,7 @@ function SidebarViewAndTypeNavigation(props: SidebarNavigationProps) {
isSectionVisible={props.isSectionVisible}
toggleVisibility={props.toggleVisibility}
onCreateNewType={props.onCreateNewType}
workspaceOrder={props.workspaceOrder}
locale={props.locale}
/>
</>
@@ -474,6 +484,18 @@ function useSidebarDndSensors() {
)
}
function invokeTypeVisibilityToggle(
onToggleTypeVisibility: SidebarProps['onToggleTypeVisibility'],
type: string,
typeEntryPath?: string,
) {
if (typeEntryPath) {
onToggleTypeVisibility?.(type, typeEntryPath)
return
}
onToggleTypeVisibility?.(type)
}
function useSidebarRuntime({
entries,
selection,
@@ -489,7 +511,13 @@ function useSidebarRuntime({
pluralizeTypeLabels = true,
locale = 'en',
}: SidebarProps) {
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, pluralizeTypeLabels)
const {
typeEntryMap,
typeVisibility,
allSectionGroups,
visibleSections,
sectionIds,
} = useSidebarSections(entries, pluralizeTypeLabels)
const { activeCount, archivedCount } = useEntryCounts(entries, allNotesFileVisibility)
const { collapsed: groupCollapsed, toggle: toggleGroup } = useSidebarCollapsed()
const typeInteractions = useSidebarTypeInteractions({
@@ -502,9 +530,11 @@ function useSidebarRuntime({
})
const isSectionVisible = useCallback((type: string) => (
(Reflect.get(typeEntryMap, type) as VaultEntry | undefined)?.visible !== false
), [typeEntryMap])
const toggleVisibility = useCallback((type: string) => onToggleTypeVisibility?.(type), [onToggleTypeVisibility])
isTypeSectionVisible(entries, type, typeVisibility)
), [entries, typeVisibility])
const toggleVisibility = useCallback((type: string, typeEntryPath?: string) => {
invokeTypeVisibilityToggle(onToggleTypeVisibility, type, typeEntryPath)
}, [onToggleTypeVisibility])
const selectTypeNote = useCallback((type: string) => {
const typeEntry = (Reflect.get(typeEntryMap, type) as VaultEntry | undefined)
?? (Reflect.get(typeEntryMap, type.toLowerCase()) as VaultEntry | undefined)
@@ -547,6 +577,7 @@ function useSidebarRuntime({
toggleGroup,
toggleVisibility,
typeEntryMap,
typeVisibility,
typeInteractions,
visibleSections,
}
@@ -581,6 +612,7 @@ function SidebarRuntimeNavigation({
onStartRenameFolder={props.onStartRenameFolder}
onCancelRenameFolder={props.onCancelRenameFolder}
vaultRootPath={props.vaultRootPath}
workspaceOrder={props.workspaceOrder}
showInbox={props.showInbox}
inboxCount={props.inboxCount}
locale={props.locale}

View File

@@ -0,0 +1,130 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { Sidebar } from './Sidebar'
import type { SidebarSelection, VaultEntry, WorkspaceIdentity } from '../types'
import { makeEntry } from '../test-utils/noteListTestUtils'
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
const mainWorkspace: WorkspaceIdentity = {
id: 'main',
label: 'Main',
alias: 'main',
path: '/vault/main',
shortLabel: 'MA',
color: 'blue',
icon: null,
mounted: true,
available: true,
defaultForNewNotes: true,
}
const workWorkspace: WorkspaceIdentity = {
id: 'work',
label: 'Work',
alias: 'work',
path: '/vault/work',
shortLabel: 'WK',
color: 'green',
icon: null,
mounted: true,
available: true,
defaultForNewNotes: false,
}
function makeWorkspaceTypeEntry(
title: string,
visible: boolean | null,
workspace: WorkspaceIdentity,
): VaultEntry {
return makeEntry({
path: `${workspace.path}/${title.toLowerCase()}.md`,
filename: `${title.toLowerCase()}.md`,
title,
isA: 'Type',
visible,
workspace,
})
}
function makeWorkspaceNote(
title: string,
type: string,
workspace: WorkspaceIdentity,
): VaultEntry {
return makeEntry({
path: `${workspace.path}/${title.toLowerCase().replaceAll(' ', '-')}.md`,
filename: `${title.toLowerCase().replaceAll(' ', '-')}.md`,
title,
isA: type,
workspace,
})
}
function renderSidebar(entries: VaultEntry[], onToggleTypeVisibility = vi.fn(), workspaceOrder?: readonly string[]) {
render(
<Sidebar
entries={entries}
selection={defaultSelection}
onSelect={() => {}}
onToggleTypeVisibility={onToggleTypeVisibility}
workspaceOrder={workspaceOrder}
/>
)
return { onToggleTypeVisibility }
}
describe('Sidebar workspace type visibility', () => {
it('shows workspace visibility columns and toggles the selected Type entry path', () => {
const entries = [
makeWorkspaceTypeEntry('Project', null, mainWorkspace),
makeWorkspaceTypeEntry('Project', false, workWorkspace),
makeWorkspaceNote('Main Project', 'Project', mainWorkspace),
makeWorkspaceNote('Work Project', 'Project', workWorkspace),
]
const { onToggleTypeVisibility } = renderSidebar(entries)
fireEvent.click(screen.getByTitle('Customize sections'))
expect(screen.getByTitle('Main (main)')).toHaveTextContent('MA')
expect(screen.getByTitle('Work (work)')).toHaveTextContent('WK')
const mainCheckbox = screen.getByRole('checkbox', { name: 'Toggle Projects MA' })
const workCheckbox = screen.getByRole('checkbox', { name: 'Toggle Projects WK' })
expect(mainCheckbox).toHaveAttribute('aria-checked', 'true')
expect(workCheckbox).toHaveAttribute('aria-checked', 'false')
fireEvent.click(workCheckbox)
expect(onToggleTypeVisibility).toHaveBeenCalledWith('Project', '/vault/work/project.md')
})
it('orders matrix columns from the vault menu order instead of Type entry discovery order', () => {
const entries = [
makeWorkspaceTypeEntry('Project', null, mainWorkspace),
makeWorkspaceTypeEntry('Project', null, workWorkspace),
makeWorkspaceNote('Main Project', 'Project', mainWorkspace),
makeWorkspaceNote('Work Project', 'Project', workWorkspace),
]
renderSidebar(entries, vi.fn(), [workWorkspace.path, mainWorkspace.path])
fireEvent.click(screen.getByTitle('Customize sections'))
const workHeader = screen.getByTitle('Work (work)')
const mainHeader = screen.getByTitle('Main (main)')
expect(workHeader.compareDocumentPosition(mainHeader) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('hides a merged section only when every workspace definition is hidden', () => {
const entries = [
makeWorkspaceTypeEntry('Project', false, mainWorkspace),
makeWorkspaceTypeEntry('Project', false, workWorkspace),
makeWorkspaceNote('Main Project', 'Project', mainWorkspace),
makeWorkspaceNote('Work Project', 'Project', workWorkspace),
]
renderSidebar(entries)
expect(screen.queryByText('Projects')).not.toBeInTheDocument()
})
})

View File

@@ -1,13 +1,19 @@
import { type ComponentType } from 'react'
import type { SidebarSelection } from '../types'
import type { SidebarSelection, VaultEntry, WorkspaceIdentity } from '../types'
import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { type IconProps } from '@phosphor-icons/react'
import { SIDEBAR_ITEM_PADDING } from './sidebar/sidebarStyles'
import { useSidebarInlineRenameInput } from './sidebar/sidebarHooks'
import { Button } from './ui/button'
import { Checkbox } from './ui/checkbox'
import { Input } from './ui/input'
import { translate, type AppLocale } from '../lib/i18n'
import { WorkspaceInitialsBadge } from './WorkspaceInitialsBadge'
import {
collectTypeVisibilityWorkspaces,
findTypeDefinitionForWorkspace,
} from '../utils/typeVisibility'
const SIDEBAR_COUNT_PILL_STYLE = {
borderRadius: 9999,
@@ -522,6 +528,8 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
)
}
type VisibilityToggleHandler = (type: string, typeEntryPath?: string) => void
function VisibilityPopoverItem({
group,
isVisible,
@@ -530,7 +538,7 @@ function VisibilityPopoverItem({
}: {
group: SectionGroup
isVisible: boolean
onToggle: (type: string) => void
onToggle: VisibilityToggleHandler
locale?: AppLocale
}) {
const { label, type, Icon, customColor } = group
@@ -553,29 +561,156 @@ function VisibilityPopoverItem({
)
}
function VisibilityMatrixHeader({ workspaces }: { workspaces: WorkspaceIdentity[] }) {
return (
<div
className="grid items-center gap-2 px-3 pb-1"
style={{ gridTemplateColumns: `minmax(96px, 1fr) repeat(${workspaces.length}, 28px)` }}
>
<div />
{workspaces.map((workspace) => (
<div key={workspace.path} className="flex justify-center">
<WorkspaceInitialsBadge workspace={workspace} />
</div>
))}
</div>
)
}
function VisibilityMatrixCell({
group,
typeEntry,
workspace,
onToggle,
locale,
}: {
group: SectionGroup
typeEntry: VaultEntry | null
workspace: WorkspaceIdentity
onToggle: VisibilityToggleHandler
locale: AppLocale
}) {
if (!typeEntry) {
return <span aria-hidden="true" className="mx-auto h-px w-3 bg-border" />
}
return (
<Checkbox
checked={typeEntry.visible !== false}
onCheckedChange={() => onToggle(group.type, typeEntry.path)}
aria-label={translate(locale, 'sidebar.section.toggle', { label: `${group.label} ${workspace.shortLabel}` })}
className="mx-auto"
/>
)
}
function VisibilityMatrixRow({
entries,
group,
locale,
onToggle,
workspaces,
}: {
entries: VaultEntry[]
group: SectionGroup
locale: AppLocale
onToggle: VisibilityToggleHandler
workspaces: WorkspaceIdentity[]
}) {
const { label, type, Icon, customColor } = group
const { sectionColor } = resolveSectionColors(type, customColor)
return (
<div
className="grid items-center gap-2 px-3 py-1.5"
style={{ gridTemplateColumns: `minmax(96px, 1fr) repeat(${workspaces.length}, 28px)` }}
>
<div className="flex min-w-0 items-center gap-2">
<Icon size={14} style={{ color: sectionColor }} />
<span className="min-w-0 truncate text-left text-[13px] text-foreground">{label}</span>
</div>
{workspaces.map((workspace) => (
<VisibilityMatrixCell
key={workspace.path}
group={group}
typeEntry={findTypeDefinitionForWorkspace(entries, type, workspace.path)}
workspace={workspace}
onToggle={onToggle}
locale={locale}
/>
))}
</div>
)
}
function VisibilityMatrixPopover({
entries,
locale,
onToggle,
sections,
workspaces,
}: {
entries: VaultEntry[]
locale: AppLocale
onToggle: VisibilityToggleHandler
sections: SectionGroup[]
workspaces: WorkspaceIdentity[]
}) {
return (
<>
<VisibilityMatrixHeader workspaces={workspaces} />
{sections.map((group) => (
<VisibilityMatrixRow
key={group.type}
entries={entries}
group={group}
locale={locale}
onToggle={onToggle}
workspaces={workspaces}
/>
))}
</>
)
}
// --- Visibility Popover ---
export function VisibilityPopover({ sections, isSectionVisible, onToggle, locale = 'en' }: {
export function VisibilityPopover({ entries, sections, isSectionVisible, onToggle, workspaceOrder = [], locale = 'en' }: {
entries: VaultEntry[]
sections: SectionGroup[]
isSectionVisible: (type: string) => boolean
onToggle: (type: string) => void
onToggle: VisibilityToggleHandler
workspaceOrder?: readonly string[]
locale?: AppLocale
}) {
const workspaces = collectTypeVisibilityWorkspaces(entries, workspaceOrder)
const showMatrix = workspaces.length > 1
return (
<div
className="border border-border bg-popover text-popover-foreground"
style={{ position: 'absolute', top: '100%', left: 6, right: 6, zIndex: 50, borderRadius: 8, padding: '8px 0', boxShadow: '0 4px 12px var(--shadow-dialog)' }}
>
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>{translate(locale, 'sidebar.section.showInSidebar')}</div>
{sections.map((group) => (
<VisibilityPopoverItem
key={group.type}
group={group}
isVisible={isSectionVisible(group.type)}
onToggle={onToggle}
{showMatrix ? (
<VisibilityMatrixPopover
entries={entries}
locale={locale}
onToggle={onToggle}
sections={sections}
workspaces={workspaces}
/>
))}
) : (
sections.map((group) => (
<VisibilityPopoverItem
key={group.type}
group={group}
isVisible={isSectionVisible(group.type)}
onToggle={onToggle}
locale={locale}
/>
))
)}
</div>
)
}

View File

@@ -2,12 +2,13 @@ import { cn } from '@/lib/utils'
import type { WorkspaceIdentity } from '../types'
interface WorkspaceInitialsBadgeProps {
ariaLabel?: string
className?: string
testId?: string
workspace?: WorkspaceIdentity | null
}
export function WorkspaceInitialsBadge({ className, testId, workspace }: WorkspaceInitialsBadgeProps) {
export function WorkspaceInitialsBadge({ ariaLabel, className, testId, workspace }: WorkspaceInitialsBadgeProps) {
if (!workspace) return null
const accentColor = workspace.color ? `var(--accent-${workspace.color})` : 'var(--muted-foreground)'
@@ -20,10 +21,10 @@ export function WorkspaceInitialsBadge({ className, testId, workspace }: Workspa
)}
style={{ borderColor: accentColor, color: accentColor }}
title={`${workspace.label} (${workspace.alias})`}
aria-label={`Workspace ${workspace.label}`}
aria-label={ariaLabel ?? `Workspace ${workspace.label}`}
data-testid={testId}
>
{workspace.shortLabel}
<span className="block h-[16px] leading-[16px]">{workspace.shortLabel}</span>
</span>
)
}

View File

@@ -240,6 +240,7 @@ function SortableSection({
}
export function TypesSection({
entries,
visibleSections,
allSectionGroups,
sectionIds,
@@ -254,8 +255,10 @@ export function TypesSection({
toggleVisibility,
onCreateNewType,
customizeRef,
workspaceOrder,
locale = 'en',
}: {
entries: VaultEntry[]
visibleSections: SectionGroup[]
allSectionGroups: SectionGroup[]
sectionIds: string[]
@@ -267,9 +270,10 @@ export function TypesSection({
showCustomize: boolean
setShowCustomize: Dispatch<SetStateAction<boolean>>
isSectionVisible: (type: string) => boolean
toggleVisibility: (type: string) => void
toggleVisibility: (type: string, typeEntryPath?: string) => void
onCreateNewType?: () => void
customizeRef: RefObject<HTMLDivElement | null>
workspaceOrder?: readonly string[]
locale?: AppLocale
}) {
return (
@@ -306,9 +310,11 @@ export function TypesSection({
</SidebarGroupHeader>
{showCustomize && (
<VisibilityPopover
entries={entries}
sections={allSectionGroups}
isSectionVisible={isSectionVisible}
onToggle={toggleVisibility}
workspaceOrder={workspaceOrder}
locale={locale}
/>
)}

View File

@@ -9,6 +9,7 @@ import { buildTypeEntryMap } from '../../utils/typeColors'
import { countAllNotesByFilter } from '../../utils/noteListHelpers'
import { buildDynamicSections, sortSections } from '../../utils/sidebarSections'
import type { AllNotesFileVisibility } from '../../utils/allNotesFileVisibility'
import { buildTypeVisibilityLookup, isTypeSectionVisible } from '../../utils/typeVisibility'
export type SidebarGroupKey = 'favorites' | 'views' | 'sections' | 'folders'
@@ -159,16 +160,17 @@ export function useSidebarInlineRenameInput({
export function useSidebarSections(entries: VaultEntry[], pluralizeTypeLabels = true) {
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
const typeVisibility = useMemo(() => buildTypeVisibilityLookup(entries), [entries])
const allSectionGroups = useMemo(() => {
const sections = buildDynamicSections(entries, typeEntryMap, pluralizeTypeLabels)
return sortSections(sections, typeEntryMap)
}, [entries, pluralizeTypeLabels, typeEntryMap])
const visibleSections = useMemo(
() => allSectionGroups.filter((group) => typeEntryMap[group.type]?.visible !== false),
[allSectionGroups, typeEntryMap],
() => allSectionGroups.filter((group) => isTypeSectionVisible(entries, group.type, typeVisibility)),
[allSectionGroups, entries, typeVisibility],
)
const sectionIds = useMemo(() => visibleSections.map((group) => group.type), [visibleSections])
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
return { typeEntryMap, typeVisibility, allSectionGroups, visibleSections, sectionIds }
}
function loadCollapsedState(): Record<SidebarGroupKey, boolean> {

View File

@@ -12,6 +12,7 @@ import { ActionTooltip } from '@/components/ui/action-tooltip'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { ConfirmDeleteDialog } from '../ConfirmDeleteDialog'
import { WorkspaceInitialsBadge } from '../WorkspaceInitialsBadge'
import { translate, type AppLocale, type TranslationKey } from '../../lib/i18n'
import { trackEvent } from '../../lib/telemetry'
import type { VaultOption } from './types'
@@ -272,20 +273,15 @@ function DefaultVaultLabel({ isDefault, locale }: { isDefault: boolean; locale:
)
}
function WorkspaceInitialsBadge({ vault }: { vault: VaultOption }) {
function VaultWorkspaceInitialsBadge({ vault }: { vault: VaultOption }) {
const workspace = workspaceIdentityFromVault(vault)
const accentColor = workspace.color ? `var(--accent-${workspace.color})` : 'var(--muted-foreground)'
return (
<span
className="inline-flex h-[16px] min-w-[18px] items-center justify-center rounded-sm border bg-transparent px-1 text-[9px] font-semibold opacity-75"
style={{ borderColor: accentColor, color: accentColor }}
title={`${workspace.label} (${workspace.alias})`}
aria-label={`Vault ${workspace.label}`}
data-testid={`vault-menu-workspace-badge-${vault.label}`}
>
{workspace.shortLabel}
</span>
<WorkspaceInitialsBadge
workspace={workspace}
ariaLabel={`Vault ${workspace.label}`}
testId={`vault-menu-workspace-badge-${vault.label}`}
/>
)
}
@@ -458,7 +454,7 @@ function VaultMenuItem({
{multiWorkspaceEnabled && (
<span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 pr-1">
<DefaultVaultLabel isDefault={isActive} locale={locale} />
<WorkspaceInitialsBadge vault={vault} />
<VaultWorkspaceInitialsBadge vault={vault} />
</span>
)}
</div>