fix: disambiguate sidebar type visibility by vault
This commit is contained in:
@@ -222,7 +222,7 @@ flowchart TD
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root.
|
||||
- **Sidebar** (220-400px, resizable): Top-level filters (All Notes, Changes, Pulse), saved Views, collapsible type-based section groups, and a dedicated folder tree. The folder tree starts with a vault-root row labeled from the opened vault path, shows root-level files when selected, and nests user-created folders plus default vault folders such as `attachments/` and `views/` underneath it; only the dedicated `type/` directory stays hidden because note types already have their own sidebar section. Saved Views persist a top-level YAML `order` field in each view file and use the same ordered-list mental model as Types for single-vault lists: pointer users can drag the existing view row, double-click to rename it, or right-click for edit/rename/appearance/delete actions, while keyboard users can use the row context key for the same menu and command-palette move actions for ordering. In multiple-vault mode, saved View rows are keyed by source vault plus filename so duplicate filenames do not collide, and edits/deletes route to the owning vault. The folder tree supports inline folder creation and rename, exposes a right-click menu for rename/delete plus filesystem reveal/copy-path actions on mutable folders, and auto-expands ancestor folders when the current selection or rename target is nested. Type sections and folder rows also act as note drop targets: dropping a note on a type updates its `type:` frontmatter, while dropping it on a folder runs the same crash-safe move path as the command palette flow. Each type can have a custom icon, color, sort, and visibility set via its `type: Type` document; new type documents created by Tolaria are written at the vault root. In mounted multi-vault graphs, duplicate type names still render as one sidebar section, but the visibility picker becomes a workspace matrix and writes visibility to the specific vault's Type document, so hidden type definitions suppress only notes of that type from the same workspace.
|
||||
- **Note List / Pulse View** (220-500px, resizable): When a section group, filter, or saved view is selected, shows filtered notes with snippets, modified dates, status indicators, and per-context note-list controls. When `selection.kind === 'entity'`, the same pane enters **Neighborhood** mode: the source note is pinned at the top as a normal active row, outgoing relationship groups render first, inverse/backlink groups follow, empty groups stay visible with `0`, and duplicates across groups are allowed when multiple relationships are true. Plain click / `Enter` open the focused note without replacing the current Neighborhood, while Cmd/Ctrl-click and Cmd/Ctrl-`Enter` pivot the pane into the clicked note's Neighborhood. Inbox organization auto-advance is coordinated by `useInboxOrganizeAdvance`, which only opens the next visible Inbox note when the organized note is still the active requested tab after the write finishes. Folder-backed lists also show non-Markdown files: previewable media and PDF binaries get file indicators and open in the editor pane, while unsupported binaries remain muted instead of auto-launching an external app. Saved views reuse the same sort and visible-column controls as the built-in lists, and those changes persist back into the view `.yml` definition (`sort`, `listPropertiesDisplay`). When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
|
||||
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with filename controls, read-only legacy display-title context when a no-H1 note's title differs from its filename, word count, rich-editor width toggle, and the secondary-overflow Table of Contents action, BlockNote rich text editor with wikilink support, Markdown-compatible inline/display math rendering, first-class Mermaid diagram blocks, markdown-safe formatting controls, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files), raw CodeMirror view, or a wide rich-editor reading surface with preserved side margins; raw CodeMirror remains full-width and unaffected by note width mode. Inline rich-editor images open in a localized shadcn lightbox on double-click while normal single-click BlockNote selection remains untouched, and tiny tracking-style images are ignored. Binary image, audio, video, and PDF files render through `FilePreview` as ordinary vault files using Tauri asset URLs; editor-embedded audio and video use the same scoped asset sources through the CSP `media-src` allow-list. Linux AppImage builds ask the native runtime whether audio/video should fall back to external-open controls before mounting webview media elements. External-open actions call `open_vault_file_external` so the target is validated against the active vault before the native default app opens it. Unsupported/broken binaries show explicit fallback states and keyboard focus returns to the note list on `Escape`. Decomposed into `Editor` (orchestrator), `EditorContent`, `FilePreview`, `EditorRightPanel`, `TableOfContentsPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, and `useEditorSave`, plus the `useRawMode`/`RawEditorView` pair for markdown source editing. Rich BlockNote input and raw CodeMirror input both route typed `->`, `<-`, and `<->` through the shared `src/utils/arrowLigatures.ts` resolver so arrow ligatures stay consistent across mode switches while escaped ASCII sequences remain literal. Navigation history (Cmd+[/]) replaces tabs.
|
||||
- **Right side panels** (200-500px or hidden): Properties, Table of Contents, and AI Agent are mutually exclusive panels mounted by `EditorRightPanel` and coordinated by `useRightPanelExclusion`. Properties shows frontmatter, relationships, instances, backlinks, and git history; Table of Contents is lazy-mounted only while open, derives a title-rooted H1/H2/H3 hierarchy through a debounced Web Worker per ADR-0109, and reuses folder-tree indentation/guide geometry with heading icons while resolving live BlockNote block IDs at click time for navigation; AI Agent keeps the selected CLI/API target controller mounted for tool execution and chat state. The breadcrumb bar toggles Table of Contents, AI, and Properties actions, and opening one replaces the others. Per-note `icon` is a suggested Properties field and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, Properties shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
|
||||
|
||||
@@ -358,6 +358,10 @@ function App() {
|
||||
vaultSwitcherLoaded: vaultSwitcher.loaded,
|
||||
windowMode: Boolean(noteWindowParams),
|
||||
})
|
||||
const vaultWorkspaceOrder = useMemo(
|
||||
() => vaultSwitcher.allVaults.map((vault) => vault.path),
|
||||
[vaultSwitcher.allVaults],
|
||||
)
|
||||
const { config: vaultConfig, updateConfig } = useVaultConfig(resolvedPath)
|
||||
const gitFeaturesEnabled = areGitFeaturesEnabled(settings)
|
||||
const handleGitSetupPreferenceChange = useCallback((preference: GitSetupPreference) => {
|
||||
@@ -1716,7 +1720,7 @@ function App() {
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
|
||||
<Sidebar entries={visibleEntries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={canReorderSavedViews ? viewOrdering.onReorderViews : undefined} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} pluralizeTypeLabels={settings.sidebar_type_pluralization_enabled ?? true} onCollapse={handleCollapseSidebar} onGoBack={handleGoBack} onGoForward={handleGoForward} canGoBack={canGoBack} canGoForward={canGoForward} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
|
||||
<Sidebar entries={visibleEntries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={canReorderSavedViews ? viewOrdering.onReorderViews : undefined} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} pluralizeTypeLabels={settings.sidebar_type_pluralization_enabled ?? true} onCollapse={handleCollapseSidebar} onGoBack={handleGoBack} onGoForward={handleGoForward} canGoBack={canGoBack} canGoForward={canGoForward} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} workspaceOrder={vaultWorkspaceOrder} />
|
||||
</div>
|
||||
<ResizeHandle onResize={layout.handleSidebarResize} />
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
130
src/components/Sidebar.typeVisibilityWorkspaces.test.tsx
Normal file
130
src/components/Sidebar.typeVisibilityWorkspaces.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -49,7 +49,11 @@ interface RenameTypeSectionArgs {
|
||||
label: string
|
||||
}
|
||||
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string): VaultEntry | undefined {
|
||||
function findTypeEntry(entries: VaultEntry[], typeName: string, typeEntryPath?: string): VaultEntry | undefined {
|
||||
if (typeEntryPath) {
|
||||
const entry = entries.find((candidate) => candidate.path === typeEntryPath)
|
||||
if (entry?.isA === 'Type') return entry
|
||||
}
|
||||
return entries.find((entry) => entry.isA === 'Type' && entry.title === typeName)
|
||||
}
|
||||
|
||||
@@ -64,9 +68,11 @@ function logOptimisticRollback(label: string, error: unknown): void {
|
||||
async function findOrCreateType(
|
||||
deps: Pick<TypeActionDeps, 'entries' | 'createTypeEntry'>,
|
||||
typeName: string,
|
||||
typeEntryPath?: string,
|
||||
): Promise<VaultEntry | null> {
|
||||
const existingType = findTypeEntry(deps.entries, typeName)
|
||||
const existingType = findTypeEntry(deps.entries, typeName, typeEntryPath)
|
||||
if (existingType) return existingType
|
||||
if (typeEntryPath) return null
|
||||
try {
|
||||
return await deps.createTypeEntry(typeName)
|
||||
} catch {
|
||||
@@ -114,8 +120,8 @@ async function renameTypeSection(deps: TypeActionDeps, args: RenameTypeSectionAr
|
||||
deps.onFrontmatterPersisted?.()
|
||||
}
|
||||
|
||||
async function toggleTypeVisibility(deps: TypeActionDeps, typeName: string): Promise<void> {
|
||||
const typeEntry = await findOrCreateType(deps, typeName)
|
||||
async function toggleTypeVisibility(deps: TypeActionDeps, typeName: string, typeEntryPath?: string): Promise<void> {
|
||||
const typeEntry = await findOrCreateType(deps, typeName, typeEntryPath)
|
||||
if (!typeEntry) return
|
||||
if (typeEntry.visible === false) {
|
||||
await deps.handleDeleteProperty(typeEntry.path, 'visible')
|
||||
@@ -202,8 +208,8 @@ function useTypeActions(deps: TypeActionDeps) {
|
||||
await renameTypeSection(typeActionDeps, { typeName, label })
|
||||
}, [typeActionDeps])
|
||||
|
||||
const handleToggleTypeVisibility = useCallback(async (typeName: string) => {
|
||||
await toggleTypeVisibility(typeActionDeps, typeName)
|
||||
const handleToggleTypeVisibility = useCallback(async (typeName: string, typeEntryPath?: string) => {
|
||||
await toggleTypeVisibility(typeActionDeps, typeName, typeEntryPath)
|
||||
}, [typeActionDeps])
|
||||
|
||||
return { handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection, handleToggleTypeVisibility }
|
||||
|
||||
38
src/hooks/useEntryActions.typeVisibility.test.ts
Normal file
38
src/hooks/useEntryActions.typeVisibility.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { useEntryActions } from './useEntryActions'
|
||||
import { makeEntry } from '../test-utils/noteListTestUtils'
|
||||
|
||||
function setup(entries: VaultEntry[]) {
|
||||
const updateEntry = vi.fn()
|
||||
const handleUpdateFrontmatter = vi.fn().mockResolvedValue(undefined)
|
||||
const handleDeleteProperty = vi.fn().mockResolvedValue(undefined)
|
||||
const createTypeEntry = vi.fn()
|
||||
const result = renderHook(() =>
|
||||
useEntryActions({
|
||||
entries,
|
||||
updateEntry,
|
||||
handleUpdateFrontmatter,
|
||||
handleDeleteProperty,
|
||||
setToastMessage: vi.fn(),
|
||||
createTypeEntry,
|
||||
})
|
||||
)
|
||||
return { ...result, createTypeEntry, handleUpdateFrontmatter, updateEntry }
|
||||
}
|
||||
|
||||
describe('useEntryActions type visibility', () => {
|
||||
it('targets the provided Type entry path when duplicate type names exist', async () => {
|
||||
const hiddenTypeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/main/journal.md', visible: false })
|
||||
const visibleTypeEntry = makeEntry({ isA: 'Type', title: 'Journal', path: '/vault/work/journal.md', visible: null })
|
||||
const { result, handleUpdateFrontmatter, updateEntry } = setup([hiddenTypeEntry, visibleTypeEntry])
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleToggleTypeVisibility('Journal', '/vault/work/journal.md')
|
||||
})
|
||||
|
||||
expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/work/journal.md', 'visible', false)
|
||||
expect(updateEntry).toHaveBeenCalledWith('/vault/work/journal.md', { visible: false })
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildRelationshipGroups, countAllByFilter, countAllNotesByFilter, countByFilter, filterEntries } from './noteListHelpers'
|
||||
import { allSelection, makeEntry, mockEntries } from '../test-utils/noteListTestUtils'
|
||||
import type { WorkspaceIdentity } from '../types'
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
describe('filterEntries', () => {
|
||||
it('returns empty for entity selections because entity view uses grouped relationships', () => {
|
||||
@@ -41,6 +68,18 @@ describe('filterEntries', () => {
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Active'])
|
||||
})
|
||||
|
||||
it('filters duplicate type sections by workspace visibility', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/main/project.md', title: 'Project', isA: 'Type', workspace: mainWorkspace, visible: false }),
|
||||
makeEntry({ path: '/vault/work/project.md', title: 'Project', isA: 'Type', workspace: workWorkspace, visible: null }),
|
||||
makeEntry({ path: '/vault/main/main-project.md', title: 'Main Project', isA: 'Project', workspace: mainWorkspace }),
|
||||
makeEntry({ path: '/vault/work/work-project.md', title: 'Work Project', isA: 'Project', workspace: workWorkspace }),
|
||||
]
|
||||
|
||||
const result = filterEntries(entries, { kind: 'sectionGroup', type: 'Project' })
|
||||
expect(result.map((entry) => entry.title)).toEqual(['Work Project'])
|
||||
})
|
||||
|
||||
it('filters all notes by open sub-filter', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/1.md', title: 'Active', isA: 'Project' }),
|
||||
@@ -196,6 +235,18 @@ describe('countByFilter', () => {
|
||||
expect(countByFilter(entries, 'Project')).toEqual({ open: 2, archived: 1 })
|
||||
})
|
||||
|
||||
it('counts duplicate type entries only from visible workspaces', () => {
|
||||
const entries = [
|
||||
makeEntry({ path: '/vault/main/project.md', title: 'Project', isA: 'Type', workspace: mainWorkspace, visible: false }),
|
||||
makeEntry({ path: '/vault/work/project.md', title: 'Project', isA: 'Type', workspace: workWorkspace, visible: null }),
|
||||
makeEntry({ path: '/vault/main/main-project.md', title: 'Main Project', isA: 'Project', workspace: mainWorkspace }),
|
||||
makeEntry({ path: '/vault/work/work-project.md', title: 'Work Project', isA: 'Project', workspace: workWorkspace }),
|
||||
makeEntry({ path: '/vault/work/archived-project.md', title: 'Archived Project', isA: 'Project', workspace: workWorkspace, archived: true }),
|
||||
]
|
||||
|
||||
expect(countByFilter(entries, 'Project')).toEqual({ open: 1, archived: 1 })
|
||||
})
|
||||
|
||||
it('returns zeros when a type has no matching entries', () => {
|
||||
expect(countByFilter([], 'Project')).toEqual({ open: 0, archived: 0 })
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { evaluateView } from './viewFilters'
|
||||
import { viewMatchesSelection } from './viewIdentity'
|
||||
import { wikilinkTarget, resolveEntry } from './wikilink'
|
||||
import { buildTypeVisibilityLookup, isSectionEntryVisibleForType } from './typeVisibility'
|
||||
|
||||
export type NoteListFilter = 'open' | 'archived'
|
||||
|
||||
@@ -518,7 +519,8 @@ function filterFolderEntries(entries: VaultEntry[], selection: Extract<SidebarSe
|
||||
}
|
||||
|
||||
function filterSectionGroupEntries(entries: VaultEntry[], type: string, subFilter?: NoteListFilter): VaultEntry[] {
|
||||
const typeEntries = entries.filter((entry) => isMarkdown(entry) && entry.isA === type)
|
||||
const typeVisibility = buildTypeVisibilityLookup(entries)
|
||||
const typeEntries = entries.filter((entry) => isSectionEntryVisibleForType(entry, type, typeVisibility))
|
||||
return subFilter ? applySubFilter(typeEntries, subFilter) : typeEntries.filter(isActive)
|
||||
}
|
||||
|
||||
@@ -565,9 +567,10 @@ export function filterEntries(
|
||||
|
||||
/** Count notes per sub-filter for a given type. */
|
||||
export function countByFilter(entries: VaultEntry[], type: string): Record<NoteListFilter, number> {
|
||||
const typeVisibility = buildTypeVisibilityLookup(entries)
|
||||
let open = 0, archived = 0
|
||||
for (const e of entries) {
|
||||
if (!isMarkdown(e) || e.isA !== type) continue
|
||||
if (!isSectionEntryVisibleForType(e, type, typeVisibility)) continue
|
||||
if (e.archived) archived++
|
||||
else open++
|
||||
}
|
||||
|
||||
110
src/utils/typeVisibility.ts
Normal file
110
src/utils/typeVisibility.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { VaultEntry, WorkspaceIdentity } from '../types'
|
||||
|
||||
const NO_WORKSPACE_KEY = '__tolaria_no_workspace__'
|
||||
|
||||
export type TypeVisibilityLookup = Record<string, Record<string, boolean>>
|
||||
|
||||
function isMarkdown(entry: VaultEntry): boolean {
|
||||
return entry.fileKind === 'markdown' || !entry.fileKind
|
||||
}
|
||||
|
||||
function typeKey(type: string): string {
|
||||
return type.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function workspaceKey(path?: string | null): string {
|
||||
return path?.trim() || NO_WORKSPACE_KEY
|
||||
}
|
||||
|
||||
function entryWorkspaceKey(entry: Pick<VaultEntry, 'workspace'>): string {
|
||||
return workspaceKey(entry.workspace?.path)
|
||||
}
|
||||
|
||||
function isActiveTypeDefinition(entry: VaultEntry): boolean {
|
||||
return isMarkdown(entry) && entry.isA === 'Type' && !entry.archived
|
||||
}
|
||||
|
||||
export function buildTypeVisibilityLookup(entries: VaultEntry[]): TypeVisibilityLookup {
|
||||
const lookup: TypeVisibilityLookup = {}
|
||||
for (const entry of entries) {
|
||||
if (!isActiveTypeDefinition(entry)) continue
|
||||
const key = typeKey(entry.title)
|
||||
if (!key) continue
|
||||
lookup[key] = lookup[key] ?? {}
|
||||
lookup[key][entryWorkspaceKey(entry)] = entry.visible !== false
|
||||
}
|
||||
return lookup
|
||||
}
|
||||
|
||||
export function isTypeVisibleInWorkspace(
|
||||
lookup: TypeVisibilityLookup,
|
||||
type: string,
|
||||
workspacePath?: string | null,
|
||||
): boolean {
|
||||
const typeLookup = lookup[typeKey(type)]
|
||||
if (!typeLookup) return true
|
||||
const visible = typeLookup[workspaceKey(workspacePath)]
|
||||
return visible !== false
|
||||
}
|
||||
|
||||
export function isSectionEntryVisibleForType(
|
||||
entry: VaultEntry,
|
||||
type: string,
|
||||
lookup: TypeVisibilityLookup,
|
||||
): boolean {
|
||||
if (!isMarkdown(entry) || entry.isA !== type) return false
|
||||
return isTypeVisibleInWorkspace(lookup, type, entry.workspace?.path)
|
||||
}
|
||||
|
||||
function isMatchingTypeDefinition(entry: VaultEntry, type: string): boolean {
|
||||
return isActiveTypeDefinition(entry) && typeKey(entry.title) === typeKey(type)
|
||||
}
|
||||
|
||||
export function isTypeSectionVisible(
|
||||
entries: VaultEntry[],
|
||||
type: string,
|
||||
lookup: TypeVisibilityLookup = buildTypeVisibilityLookup(entries),
|
||||
): boolean {
|
||||
let hasMatchingTypeDefinition = false
|
||||
|
||||
for (const entry of entries) {
|
||||
if (isSectionEntryVisibleForType(entry, type, lookup)) return true
|
||||
if (!isMatchingTypeDefinition(entry, type)) continue
|
||||
hasMatchingTypeDefinition = true
|
||||
if (isTypeVisibleInWorkspace(lookup, type, entry.workspace?.path)) return true
|
||||
}
|
||||
|
||||
return !hasMatchingTypeDefinition
|
||||
}
|
||||
|
||||
function workspaceOrderIndex(workspace: WorkspaceIdentity, orderedWorkspacePaths: readonly string[]): number {
|
||||
const index = orderedWorkspacePaths.indexOf(workspace.path)
|
||||
return index === -1 ? Number.MAX_SAFE_INTEGER : index
|
||||
}
|
||||
|
||||
export function collectTypeVisibilityWorkspaces(
|
||||
entries: VaultEntry[],
|
||||
orderedWorkspacePaths: readonly string[] = [],
|
||||
): WorkspaceIdentity[] {
|
||||
const workspacesByPath = new Map<string, WorkspaceIdentity>()
|
||||
for (const entry of entries) {
|
||||
const workspace = entry.workspace
|
||||
if (!workspace || workspacesByPath.has(workspace.path)) continue
|
||||
workspacesByPath.set(workspace.path, workspace)
|
||||
}
|
||||
return [...workspacesByPath.values()].sort((a, b) => (
|
||||
workspaceOrderIndex(a, orderedWorkspacePaths) - workspaceOrderIndex(b, orderedWorkspacePaths)
|
||||
))
|
||||
}
|
||||
|
||||
export function findTypeDefinitionForWorkspace(
|
||||
entries: VaultEntry[],
|
||||
type: string,
|
||||
workspacePath: string,
|
||||
): VaultEntry | null {
|
||||
const key = typeKey(type)
|
||||
return entries.find((entry) => (
|
||||
isMatchingTypeDefinition(entry, key)
|
||||
&& entry.workspace?.path === workspacePath
|
||||
)) ?? null
|
||||
}
|
||||
Reference in New Issue
Block a user