From 27930249045b4fc099d50ef74e3776262cf418cc Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 2 Mar 2026 21:32:41 +0100 Subject: [PATCH] feat: add Rename section to sidebar context menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add handleRenameSection to useEntryActions: writes/deletes 'sidebar label' frontmatter key on the Type note with optimistic in-memory update - Add InlineRenameInput to SidebarParts: autoFocus input rendered in-place of section title, submits on Enter/blur, cancels on Escape - Add 'Rename section…' as first item in sidebar section context menu - Wire onRenameSection from App.tsx through Sidebar props - Add 10 new tests (4 unit, 6 component) covering the full rename flow Co-Authored-By: Claude Sonnet 4.6 --- src/App.tsx | 2 +- src/components/Sidebar.test.tsx | 57 +++++++++++++++++++ src/components/Sidebar.tsx | 43 +++++++++++--- src/components/SidebarParts.tsx | 93 +++++++++++++++++++++++++------ src/hooks/useEntryActions.test.ts | 50 +++++++++++++++++ src/hooks/useEntryActions.ts | 14 ++++- 6 files changed, 234 insertions(+), 25 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b04e7ea8..b1dc40ba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -355,7 +355,7 @@ function App() { {sidebarVisible && ( <>
- +
diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index 821b1ce2..be9f000f 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -815,4 +815,61 @@ describe('Sidebar', () => { expect(dragHandles.length).toBe(0) }) }) + + describe('rename section via context menu', () => { + it('shows Rename section option in context menu on right-click', () => { + render( {}} />) + const projectHeader = screen.getByText('Projects').closest('div')! + fireEvent.contextMenu(projectHeader) + expect(screen.getByText('Rename section…')).toBeInTheDocument() + }) + + it('shows Customize icon option in context menu on right-click', () => { + render( {}} />) + const projectHeader = screen.getByText('Projects').closest('div')! + fireEvent.contextMenu(projectHeader) + expect(screen.getByText('Customize icon & color…')).toBeInTheDocument() + }) + + it('shows inline input when Rename section is clicked', () => { + render( {}} />) + const projectHeader = screen.getByText('Projects').closest('div')! + fireEvent.contextMenu(projectHeader) + fireEvent.click(screen.getByText('Rename section…')) + expect(screen.getByRole('textbox', { name: 'Section name' })).toBeInTheDocument() + }) + + it('inline input is pre-filled with current label', () => { + render( {}} />) + const projectHeader = screen.getByText('Projects').closest('div')! + fireEvent.contextMenu(projectHeader) + fireEvent.click(screen.getByText('Rename section…')) + const input = screen.getByRole('textbox', { name: 'Section name' }) as HTMLInputElement + expect(input.value).toBe('Projects') + }) + + it('calls onRenameSection with new name on Enter', () => { + const onRenameSection = vi.fn() + render( {}} onRenameSection={onRenameSection} />) + const projectHeader = screen.getByText('Projects').closest('div')! + fireEvent.contextMenu(projectHeader) + fireEvent.click(screen.getByText('Rename section…')) + const input = screen.getByRole('textbox', { name: 'Section name' }) + fireEvent.change(input, { target: { value: 'My Projects' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + expect(onRenameSection).toHaveBeenCalledWith('Project', 'My Projects') + }) + + it('cancels rename on Escape and hides input', () => { + const onRenameSection = vi.fn() + render( {}} onRenameSection={onRenameSection} />) + const projectHeader = screen.getByText('Projects').closest('div')! + fireEvent.contextMenu(projectHeader) + fireEvent.click(screen.getByText('Rename section…')) + const input = screen.getByRole('textbox', { name: 'Section name' }) + fireEvent.keyDown(input, { key: 'Escape' }) + expect(onRenameSection).not.toHaveBeenCalled() + expect(screen.queryByRole('textbox', { name: 'Section name' })).not.toBeInTheDocument() + }) + }) }) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 0830cca1..6be9ba6f 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -34,6 +34,7 @@ interface SidebarProps { onCustomizeType?: (typeName: string, icon: string, color: string) => void onUpdateTypeTemplate?: (typeName: string, template: string) => void onReorderSections?: (orderedTypes: { typeName: string; order: number }[]) => void + onRenameSection?: (typeName: string, label: string) => void modifiedCount?: number onCommitPush?: () => void onCollapse?: () => void @@ -162,12 +163,13 @@ function applyCustomization( function SortableSection({ group, sectionProps }: { group: SectionGroup - sectionProps: Omit - & { entries: VaultEntry[]; collapsed: Record; onToggle: (type: string) => void } + sectionProps: Omit + & { entries: VaultEntry[]; collapsed: Record; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string } }) { const { attributes, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type }) const items = sectionProps.entries.filter((e) => e.isA === group.type && !e.archived && !e.trashed) const isCollapsed = sectionProps.collapsed[group.type] ?? true + const isRenaming = sectionProps.renamingType === group.type return (
@@ -177,6 +179,11 @@ function SortableSection({ group, sectionProps }: { onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType} onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu} onToggle={() => sectionProps.onToggle(group.type)} + dragHandleProps={listeners} + isRenaming={isRenaming} + renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined} + onRenameSubmit={sectionProps.onRenameSubmit} + onRenameCancel={sectionProps.onRenameCancel} />
) @@ -216,16 +223,21 @@ function SidebarTitleBar({ onCollapse }: { onCollapse?: () => void }) { ) } -function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: { +function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize, onStartRename }: { pos: { x: number; y: number } | null; type: string | null innerRef: React.Ref onOpenCustomize: (type: string) => void + onStartRename: (type: string) => void }) { if (!pos || !type) return null + const btnClass = "flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm cursor-default hover:bg-accent hover:text-accent-foreground transition-colors border-none bg-transparent text-left" return (
- +
) @@ -258,13 +270,16 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, - onCustomizeType, onUpdateTypeTemplate, onReorderSections, modifiedCount = 0, onCommitPush, onCollapse, + onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection, + modifiedCount = 0, onCommitPush, onCollapse, }: SidebarProps) { const [collapsed, setCollapsed] = useState>({}) const [customizeTarget, setCustomizeTarget] = useState(null) const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null) const [contextMenuType, setContextMenuType] = useState(null) const [showCustomize, setShowCustomize] = useState(false) + const [renamingType, setRenamingType] = useState(null) + const [renameInitialValue, setRenameInitialValue] = useState('') const contextMenuRef = useRef(null) const popoverRef = useRef(null) @@ -277,6 +292,7 @@ export const Sidebar = memo(function Sidebar({ const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, []) const closeCustomize = useCallback(() => setShowCustomize(false), []) const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), []) + const cancelRename = useCallback(() => setRenamingType(null), []) useOutsideClick(customizeRef, showCustomize, closeCustomize) useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu) @@ -303,6 +319,18 @@ export const Sidebar = memo(function Sidebar({ setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type) }, []) + const handleStartRename = useCallback((type: string) => { + closeContextMenu() + const group = allSectionGroups.find((g) => g.type === type) + setRenameInitialValue(group?.label ?? type) + setRenamingType(type) + }, [closeContextMenu, allSectionGroups]) + + const handleRenameSubmit = useCallback((value: string) => { + if (renamingType) onRenameSection?.(renamingType, value) + setRenamingType(null) + }, [renamingType, onRenameSection]) + const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => { applyCustomization(customizeTarget, typeEntryMap, onCustomizeType, prop, value) }, [customizeTarget, typeEntryMap, onCustomizeType]) @@ -314,6 +342,7 @@ export const Sidebar = memo(function Sidebar({ const sectionProps = { entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onContextMenu: handleContextMenu, onToggle: toggleSection, + renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename, } return ( @@ -354,7 +383,7 @@ export const Sidebar = memo(function Sidebar({ - { closeContextMenu(); setCustomizeTarget(type) }} /> + { closeContextMenu(); setCustomizeTarget(type) }} onStartRename={handleStartRename} /> ) diff --git a/src/components/SidebarParts.tsx b/src/components/SidebarParts.tsx index 1995aef2..46e8d72b 100644 --- a/src/components/SidebarParts.tsx +++ b/src/components/SidebarParts.tsx @@ -1,4 +1,4 @@ -import { type ComponentType } from 'react' +import { type ComponentType, useState, useEffect, useRef } from 'react' import type { VaultEntry, SidebarSelection } from '../types' import { cn } from '@/lib/utils' import { ChevronRight, ChevronDown, Plus } from 'lucide-react' @@ -75,6 +75,11 @@ export interface SectionContentProps { onCreateNewType?: () => void onContextMenu: (e: React.MouseEvent, type: string) => void onToggle: () => void + dragHandleProps?: Record + isRenaming?: boolean + renameInitialValue?: string + onRenameSubmit?: (value: string) => void + onRenameCancel?: () => void } function childSelection(type: string, entry: VaultEntry): SidebarSelection { @@ -89,7 +94,8 @@ function resolveCreateHandler(type: string, onCreateType?: (type: string) => voi export function SectionContent({ group, items, isCollapsed, selection, onSelect, onSelectNote, - onCreateType, onCreateNewType, onContextMenu, onToggle, + onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps, + isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel, }: SectionContentProps) { const { label, type, Icon, customColor } = group const sectionColor = getTypeColor(type, customColor) @@ -108,6 +114,11 @@ export function SectionContent({ onContextMenu={(e) => onContextMenu(e, type)} onToggle={onToggle} onCreate={(e) => { e.stopPropagation(); onCreate?.() }} + dragHandleProps={dragHandleProps} + isRenaming={isRenaming} + renameInitialValue={renameInitialValue} + onRenameSubmit={onRenameSubmit} + onRenameCancel={onRenameCancel} /> {!isCollapsed && items.length > 0 && ( void + onCancel: () => void +}) { + const [value, setValue] = useState(initialValue) + const inputRef = useRef(null) + + useEffect(() => { inputRef.current?.focus(); inputRef.current?.select() }, []) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); onSubmit(value.trim()) } + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onCancel() } + } + + return ( + setValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => onSubmit(value.trim())} + onClick={(e) => e.stopPropagation()} + className="flex-1 rounded border border-primary bg-background text-[13px] font-medium text-foreground outline-none" + style={{ padding: '0 4px', marginLeft: 4, minWidth: 0 }} + aria-label="Section name" + /> + ) +} + +function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: { label: string; type: string; Icon: ComponentType sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void onToggle: () => void; onCreate: (e: React.MouseEvent) => void + dragHandleProps?: Record + isRenaming?: boolean + renameInitialValue?: string + onRenameSubmit?: (value: string) => void + onRenameCancel?: () => void }) { return (
{ + if (isRenaming) return if (isCollapsed) { onToggle(); onSelect() } else if (isActive) { onToggle() } else { onSelect() } - }} onContextMenu={onContextMenu} + }} onContextMenu={isRenaming ? undefined : onContextMenu} > -
- - {label} -
-
- {showCreate && ( - +
+
+ +
+ + {isRenaming && onRenameSubmit && onRenameCancel ? ( + + ) : ( + {label} )} -
+ {!isRenaming && ( +
+ {showCreate && ( + + )} + +
+ )}
) } diff --git a/src/hooks/useEntryActions.test.ts b/src/hooks/useEntryActions.test.ts index 6fc87674..a6681c5c 100644 --- a/src/hooks/useEntryActions.test.ts +++ b/src/hooks/useEntryActions.test.ts @@ -238,4 +238,54 @@ describe('useEntryActions', () => { expect(updateEntry).toHaveBeenCalledTimes(1) }) }) + + describe('handleRenameSection', () => { + it('writes sidebar label frontmatter and updates entry in memory', async () => { + const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null }) + const { result } = setup([typeEntry]) + + await act(async () => { + await result.current.handleRenameSection('Recipe', 'Recipes') + }) + + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Recipes') + expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Recipes' }) + }) + + it('trims whitespace before saving', async () => { + const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: null }) + const { result } = setup([typeEntry]) + + await act(async () => { + await result.current.handleRenameSection('Recipe', ' Dishes ') + }) + + expect(handleUpdateFrontmatter).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label', 'Dishes') + expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: 'Dishes' }) + }) + + it('deletes sidebar label when label is empty', async () => { + const typeEntry = makeEntry({ isA: 'Type', title: 'Recipe', path: '/vault/type/recipe.md', sidebarLabel: 'Dishes' }) + const { result } = setup([typeEntry]) + + await act(async () => { + await result.current.handleRenameSection('Recipe', '') + }) + + expect(handleDeleteProperty).toHaveBeenCalledWith('/vault/type/recipe.md', 'sidebar label') + expect(updateEntry).toHaveBeenCalledWith('/vault/type/recipe.md', { sidebarLabel: null }) + expect(handleUpdateFrontmatter).not.toHaveBeenCalled() + }) + + it('does nothing when type entry not found', async () => { + const { result } = setup([]) + + await act(async () => { + await result.current.handleRenameSection('NonExistent', 'Label') + }) + + expect(handleUpdateFrontmatter).not.toHaveBeenCalled() + expect(updateEntry).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/hooks/useEntryActions.ts b/src/hooks/useEntryActions.ts index 667853bd..9bf63611 100644 --- a/src/hooks/useEntryActions.ts +++ b/src/hooks/useEntryActions.ts @@ -68,5 +68,17 @@ export function useEntryActions({ updateEntry(typeEntry.path, { template: template || null }) }, [entries, handleUpdateFrontmatter, updateEntry]) - return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate } + const handleRenameSection = useCallback(async (typeName: string, label: string) => { + const typeEntry = findTypeEntry(entries, typeName) + if (!typeEntry) return + const trimmed = label.trim() + updateEntry(typeEntry.path, { sidebarLabel: trimmed || null }) + if (trimmed) { + await handleUpdateFrontmatter(typeEntry.path, 'sidebar label', trimmed) + } else { + await handleDeleteProperty(typeEntry.path, 'sidebar label') + } + }, [entries, handleUpdateFrontmatter, handleDeleteProperty, updateEntry]) + + return { handleTrashNote, handleRestoreNote, handleArchiveNote, handleUnarchiveNote, handleCustomizeType, handleReorderSections, handleUpdateTypeTemplate, handleRenameSection } }