diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 38589bec..faa87f0a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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). diff --git a/src/App.tsx b/src/App.tsx index bbd62df1..8fa69540 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 && ( <>
- +
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b22c5f5e..ef4de231 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 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 ( ) @@ -423,6 +431,7 @@ function SidebarViewAndTypeNavigation(props: SidebarNavigationProps) { )} @@ -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} diff --git a/src/components/Sidebar.typeVisibilityWorkspaces.test.tsx b/src/components/Sidebar.typeVisibilityWorkspaces.test.tsx new file mode 100644 index 00000000..34de4369 --- /dev/null +++ b/src/components/Sidebar.typeVisibilityWorkspaces.test.tsx @@ -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( + {}} + 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() + }) +}) diff --git a/src/components/SidebarParts.tsx b/src/components/SidebarParts.tsx index 9ab12a45..26951f0f 100644 --- a/src/components/SidebarParts.tsx +++ b/src/components/SidebarParts.tsx @@ -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 ( +
+
+ {workspaces.map((workspace) => ( +
+ +
+ ))} +
+ ) +} + +function VisibilityMatrixCell({ + group, + typeEntry, + workspace, + onToggle, + locale, +}: { + group: SectionGroup + typeEntry: VaultEntry | null + workspace: WorkspaceIdentity + onToggle: VisibilityToggleHandler + locale: AppLocale +}) { + if (!typeEntry) { + return ) } diff --git a/src/components/sidebar/SidebarSections.tsx b/src/components/sidebar/SidebarSections.tsx index 8c5a2418..4108bfa6 100644 --- a/src/components/sidebar/SidebarSections.tsx +++ b/src/components/sidebar/SidebarSections.tsx @@ -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> isSectionVisible: (type: string) => boolean - toggleVisibility: (type: string) => void + toggleVisibility: (type: string, typeEntryPath?: string) => void onCreateNewType?: () => void customizeRef: RefObject + workspaceOrder?: readonly string[] locale?: AppLocale }) { return ( @@ -306,9 +310,11 @@ export function TypesSection({ {showCustomize && ( )} diff --git a/src/components/sidebar/sidebarHooks.ts b/src/components/sidebar/sidebarHooks.ts index 0632f546..9d579184 100644 --- a/src/components/sidebar/sidebarHooks.ts +++ b/src/components/sidebar/sidebarHooks.ts @@ -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 { diff --git a/src/components/status-bar/VaultMenu.tsx b/src/components/status-bar/VaultMenu.tsx index 14bfc79a..e4804fb4 100644 --- a/src/components/status-bar/VaultMenu.tsx +++ b/src/components/status-bar/VaultMenu.tsx @@ -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 ( - - {workspace.shortLabel} - + ) } @@ -458,7 +454,7 @@ function VaultMenuItem({ {multiWorkspaceEnabled && ( - + )}
diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index 43643fd1..dfdcd1df 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -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, typeName: string, + typeEntryPath?: string, ): Promise { - 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 { - const typeEntry = await findOrCreateType(deps, typeName) +async function toggleTypeVisibility(deps: TypeActionDeps, typeName: string, typeEntryPath?: string): Promise { + 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 } diff --git a/src/hooks/useEntryActions.typeVisibility.test.ts b/src/hooks/useEntryActions.typeVisibility.test.ts new file mode 100644 index 00000000..32401948 --- /dev/null +++ b/src/hooks/useEntryActions.typeVisibility.test.ts @@ -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 }) + }) +}) diff --git a/src/utils/noteListHelpers.test.ts b/src/utils/noteListHelpers.test.ts index a72c8dff..f474545f 100644 --- a/src/utils/noteListHelpers.test.ts +++ b/src/utils/noteListHelpers.test.ts @@ -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 }) }) diff --git a/src/utils/noteListHelpers.ts b/src/utils/noteListHelpers.ts index 8b646da4..b6356501 100644 --- a/src/utils/noteListHelpers.ts +++ b/src/utils/noteListHelpers.ts @@ -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 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 { + 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++ } diff --git a/src/utils/typeVisibility.ts b/src/utils/typeVisibility.ts new file mode 100644 index 00000000..0d4267f9 --- /dev/null +++ b/src/utils/typeVisibility.ts @@ -0,0 +1,110 @@ +import type { VaultEntry, WorkspaceIdentity } from '../types' + +const NO_WORKSPACE_KEY = '__tolaria_no_workspace__' + +export type TypeVisibilityLookup = Record> + +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): 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() + 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 +}