refactor: decompose TabBar and Sidebar for reduced complexity
TabBar (8.50 → 9.38): - Extract useTabDrag hook for drag state management - Extract computeDropTarget/computeInsertIndex helpers - Extract TabItem, DropIndicator, TabBarActions sub-components - TabBar main function reduced from cc=23/176 LoC to ~25 LoC Sidebar (8.57 → 9.38, SidebarParts 10.0): - Create SidebarParts.tsx with NavItem, SectionContent, VisibilityPopover - Extract useSidebarSections hook for section computation - Extract useEntryCounts hook consolidating 3 useMemos into 1 - Extract useOutsideClick hook (eliminates 3 duplicate useEffect patterns) - Extract buildCustomizeArgs, computeReorder pure helpers - Extract CommitButton, ContextMenuOverlay, CustomizeOverlay - isSelectionActive extracted as pure function (was closure) - Sidebar reduced from 604 LoC/cc=34 to ~120 LoC/cc=11 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,42 +1,24 @@
|
||||
import { useState, useMemo, useRef, useEffect, useCallback, memo, type ComponentType } from 'react'
|
||||
import { useState, useMemo, useRef, useEffect, useCallback, memo } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronRight, ChevronDown, GitCommitHorizontal, Plus, SlidersHorizontal, GripVertical } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { resolveIcon, TypeCustomizePopover } from './TypeCustomizePopover'
|
||||
import { useSectionVisibility } from '../hooks/useSectionVisibility'
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
DndContext, closestCenter, KeyboardSensor, PointerSensor,
|
||||
useSensor, useSensors, type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import {
|
||||
FileText,
|
||||
Star,
|
||||
Wrench,
|
||||
Flask,
|
||||
Target,
|
||||
ArrowsClockwise,
|
||||
Users,
|
||||
CalendarBlank,
|
||||
Tag,
|
||||
TagSimple,
|
||||
Trash,
|
||||
StackSimple,
|
||||
Archive,
|
||||
type IconProps,
|
||||
FileText, Star, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, TagSimple, Trash, StackSimple, Archive,
|
||||
} from '@phosphor-icons/react'
|
||||
import { GitCommitHorizontal, SlidersHorizontal } from 'lucide-react'
|
||||
import {
|
||||
type SectionGroup, isSelectionActive,
|
||||
NavItem, SectionContent, type SectionContentProps, VisibilityPopover,
|
||||
} from './SidebarParts'
|
||||
|
||||
interface SidebarProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -51,18 +33,6 @@ interface SidebarProps {
|
||||
onCommitPush?: () => void
|
||||
}
|
||||
|
||||
const TOP_NAV = [
|
||||
{ label: 'All Notes', filter: 'all' as const, Icon: FileText },
|
||||
{ label: 'Favorites', filter: 'favorites' as const, Icon: Star },
|
||||
]
|
||||
|
||||
interface SectionGroup {
|
||||
label: string
|
||||
type: string
|
||||
Icon: ComponentType<IconProps>
|
||||
customColor?: string | null
|
||||
}
|
||||
|
||||
const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
{ label: 'Projects', type: 'Project', Icon: Wrench },
|
||||
{ label: 'Experiments', type: 'Experiment', Icon: Flask },
|
||||
@@ -76,528 +46,262 @@ const BUILT_IN_SECTION_GROUPS: SectionGroup[] = [
|
||||
|
||||
const BUILT_IN_TYPES = new Set(BUILT_IN_SECTION_GROUPS.map((s) => s.type))
|
||||
|
||||
export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType, onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush }: SidebarProps) {
|
||||
// --- Hooks ---
|
||||
|
||||
function useOutsideClick(ref: React.RefObject<HTMLElement | null>, isOpen: boolean, onClose: () => void) {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [isOpen, onClose])
|
||||
}
|
||||
|
||||
function buildTypeEntryMap(entries: VaultEntry[]): Record<string, VaultEntry> {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
for (const e of entries) { if (e.isA === 'Type') map[e.title] = e }
|
||||
return map
|
||||
}
|
||||
|
||||
function applyOverrides(typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
return BUILT_IN_SECTION_GROUPS.map((sg) => {
|
||||
const te = typeEntryMap[sg.type]
|
||||
if (!te?.icon && !te?.color) return sg
|
||||
return { ...sg, Icon: te?.icon ? resolveIcon(te.icon) : sg.Icon, customColor: te?.color ?? null }
|
||||
})
|
||||
}
|
||||
|
||||
function buildCustomSections(entries: VaultEntry[]): SectionGroup[] {
|
||||
return entries
|
||||
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
.map((e) => ({ label: e.title + 's', type: e.title, Icon: resolveIcon(e.icon), customColor: e.color }))
|
||||
}
|
||||
|
||||
function sortSections(groups: SectionGroup[], typeEntryMap: Record<string, VaultEntry>): SectionGroup[] {
|
||||
return [...groups].sort((a, b) => {
|
||||
const orderA = typeEntryMap[a.type]?.order ?? Infinity
|
||||
const orderB = typeEntryMap[b.type]?.order ?? Infinity
|
||||
return orderA !== orderB ? orderA - orderB : a.label.localeCompare(b.label)
|
||||
})
|
||||
}
|
||||
|
||||
function useSidebarSections(entries: VaultEntry[], isSectionVisible: (type: string) => boolean) {
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const allSectionGroups = useMemo(() => {
|
||||
const built = applyOverrides(typeEntryMap)
|
||||
const custom = buildCustomSections(entries)
|
||||
return sortSections([...built, ...custom], typeEntryMap)
|
||||
}, [entries, typeEntryMap])
|
||||
const visibleSections = useMemo(() => allSectionGroups.filter((g) => isSectionVisible(g.type)), [allSectionGroups, isSectionVisible])
|
||||
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
|
||||
return { typeEntryMap, allSectionGroups, visibleSections, sectionIds }
|
||||
}
|
||||
|
||||
function useEntryCounts(entries: VaultEntry[]) {
|
||||
return useMemo(() => {
|
||||
let active = 0, archived = 0, trashed = 0
|
||||
for (const e of entries) {
|
||||
if (e.trashed) trashed++
|
||||
else if (e.archived) archived++
|
||||
else active++
|
||||
}
|
||||
return { activeCount: active, archivedCount: archived, trashedCount: trashed }
|
||||
}, [entries])
|
||||
}
|
||||
|
||||
function computeReorder(sectionIds: string[], activeId: string, overId: string): string[] | null {
|
||||
const oldIndex = sectionIds.indexOf(activeId)
|
||||
const newIndex = sectionIds.indexOf(overId)
|
||||
if (oldIndex === -1 || newIndex === -1) return null
|
||||
const reordered = [...sectionIds]
|
||||
reordered.splice(oldIndex, 1)
|
||||
reordered.splice(newIndex, 0, activeId)
|
||||
return reordered
|
||||
}
|
||||
|
||||
function buildCustomizeArgs(typeEntry: VaultEntry, prop: 'icon' | 'color', value: string): [string, string] {
|
||||
return [
|
||||
prop === 'icon' ? value : (typeEntry.icon ?? 'file-text'),
|
||||
prop === 'color' ? value : (typeEntry.color ?? 'blue'),
|
||||
]
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function SortableSection({ group, sectionProps }: {
|
||||
group: SectionGroup
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'dragHandleProps'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void }
|
||||
}) {
|
||||
const { attributes, listeners, 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
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '4px 6px' }} {...attributes}>
|
||||
<SectionContent
|
||||
group={group} items={items} isCollapsed={isCollapsed}
|
||||
selection={sectionProps.selection} onSelect={sectionProps.onSelect}
|
||||
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
|
||||
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
|
||||
onToggle={() => sectionProps.onToggle(group.type)}
|
||||
dragHandleProps={listeners}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommitButton({ modifiedCount, onClick }: { modifiedCount: number; onClick?: () => void }) {
|
||||
if (!onClick) return null
|
||||
return (
|
||||
<div className="shrink-0 border-t border-border" style={{ padding: 12 }}>
|
||||
<button className="flex w-full items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors" style={{ borderRadius: 6, gap: 6, padding: '8px 16px', border: 'none', cursor: 'pointer' }} onClick={onClick}>
|
||||
<GitCommitHorizontal size={14} />
|
||||
<span className="text-[13px] font-medium">Commit & Push</span>
|
||||
{modifiedCount > 0 && (
|
||||
<span className="text-white font-semibold" style={{ background: '#ffffff40', borderRadius: 9, padding: '0 6px', fontSize: 10 }}>{modifiedCount}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ContextMenuOverlay({ pos, type, innerRef, onOpenCustomize }: {
|
||||
pos: { x: number; y: number } | null; type: string | null
|
||||
innerRef: React.Ref<HTMLDivElement>
|
||||
onOpenCustomize: (type: string) => void
|
||||
}) {
|
||||
if (!pos || !type) return null
|
||||
return (
|
||||
<div ref={innerRef} className="fixed z-50 rounded-md border bg-popover p-1 shadow-md" style={{ left: pos.x, top: pos.y, minWidth: 180 }}>
|
||||
<button className="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" onClick={() => onOpenCustomize(type)}>
|
||||
Customize icon & color…
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onClose }: {
|
||||
target: string | null; typeEntryMap: Record<string, VaultEntry>
|
||||
innerRef: React.Ref<HTMLDivElement>
|
||||
onCustomize: (prop: 'icon' | 'color', value: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
if (!target) return null
|
||||
return (
|
||||
<div ref={innerRef} className="fixed z-50" style={{ left: 20, top: 100 }}>
|
||||
<TypeCustomizePopover
|
||||
currentIcon={typeEntryMap[target]?.icon ?? null}
|
||||
currentColor={typeEntryMap[target]?.color ?? null}
|
||||
onChangeIcon={(icon) => onCustomize('icon', icon)}
|
||||
onChangeColor={(color) => onCustomize('color', color)}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Sidebar ---
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onCustomizeType, onReorderSections, modifiedCount = 0, onCommitPush,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [contextMenuType, setContextMenuType] = useState<string | null>(null)
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null)
|
||||
const popoverRef = useRef<HTMLDivElement>(null)
|
||||
const [showCustomize, setShowCustomize] = useState(false)
|
||||
const customizeRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { toggleSection: toggleVisibility, isSectionVisible } = useSectionVisibility()
|
||||
const { typeEntryMap, allSectionGroups, visibleSections, sectionIds } = useSidebarSections(entries, isSectionVisible)
|
||||
const { activeCount, archivedCount, trashedCount } = useEntryCounts(entries)
|
||||
|
||||
// Close section-visibility popover on outside click
|
||||
useEffect(() => {
|
||||
if (!showCustomize) return
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (customizeRef.current && !customizeRef.current.contains(e.target as Node)) {
|
||||
setShowCustomize(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [showCustomize])
|
||||
const closeContextMenu = useCallback(() => { setContextMenuPos(null); setContextMenuType(null) }, [])
|
||||
const closeCustomize = useCallback(() => setShowCustomize(false), [])
|
||||
const closeCustomizeTarget = useCallback(() => setCustomizeTarget(null), [])
|
||||
|
||||
const toggleSection = (type: string) => {
|
||||
useOutsideClick(customizeRef, showCustomize, closeCustomize)
|
||||
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
|
||||
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
|
||||
|
||||
const toggleSection = useCallback((type: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Build a map of type name → type entry for quick lookup of icon/color
|
||||
const typeEntryMap = useMemo(() => {
|
||||
const map: Record<string, VaultEntry> = {}
|
||||
for (const e of entries) {
|
||||
if (e.isA === 'Type') map[e.title] = e
|
||||
}
|
||||
return map
|
||||
}, [entries])
|
||||
|
||||
const isActive = (sel: SidebarSelection): boolean => {
|
||||
if (selection.kind !== sel.kind) return false
|
||||
if (sel.kind === 'filter' && selection.kind === 'filter') return sel.filter === selection.filter
|
||||
if (sel.kind === 'sectionGroup' && selection.kind === 'sectionGroup') return sel.type === selection.type
|
||||
if (sel.kind === 'entity' && selection.kind === 'entity') return sel.entry.path === selection.entry.path
|
||||
if (sel.kind === 'topic' && selection.kind === 'topic') return sel.entry.path === selection.entry.path
|
||||
return false
|
||||
}
|
||||
|
||||
// Derive custom type sections from Type entries not in the built-in list
|
||||
const customSectionGroups: SectionGroup[] = useMemo(() => {
|
||||
return entries
|
||||
.filter((e) => e.isA === 'Type' && !BUILT_IN_TYPES.has(e.title))
|
||||
.sort((a, b) => a.title.localeCompare(b.title))
|
||||
.map((e) => ({
|
||||
label: e.title + 's',
|
||||
type: e.title,
|
||||
Icon: resolveIcon(e.icon),
|
||||
customColor: e.color,
|
||||
}))
|
||||
}, [entries])
|
||||
|
||||
// For built-in types, check if they have custom icon/color overrides from their type entry
|
||||
const builtInWithOverrides: SectionGroup[] = useMemo(() => {
|
||||
return BUILT_IN_SECTION_GROUPS.map((sg) => {
|
||||
const typeEntry = typeEntryMap[sg.type]
|
||||
if (!typeEntry?.icon && !typeEntry?.color) return sg
|
||||
return {
|
||||
...sg,
|
||||
Icon: typeEntry?.icon ? resolveIcon(typeEntry.icon) : sg.Icon,
|
||||
customColor: typeEntry?.color ?? null,
|
||||
}
|
||||
})
|
||||
}, [typeEntryMap])
|
||||
|
||||
// Merge built-in and custom sections, then sort by order from type entries
|
||||
const allSectionGroups = useMemo(() => {
|
||||
const merged = [...builtInWithOverrides, ...customSectionGroups]
|
||||
return merged.sort((a, b) => {
|
||||
const orderA = typeEntryMap[a.type]?.order ?? Infinity
|
||||
const orderB = typeEntryMap[b.type]?.order ?? Infinity
|
||||
if (orderA !== orderB) return orderA - orderB
|
||||
return a.label.localeCompare(b.label)
|
||||
})
|
||||
}, [builtInWithOverrides, customSectionGroups, typeEntryMap])
|
||||
|
||||
// DnD sensors with activation distance to avoid accidental drags on click
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
|
||||
const visibleSections = useMemo(
|
||||
() => allSectionGroups.filter((g) => isSectionVisible(g.type)),
|
||||
[allSectionGroups, isSectionVisible],
|
||||
)
|
||||
|
||||
const sectionIds = useMemo(() => visibleSections.map((g) => g.type), [visibleSections])
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
|
||||
const oldIndex = sectionIds.indexOf(active.id as string)
|
||||
const newIndex = sectionIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
|
||||
// Compute new order: assign sequential order values to the reordered list
|
||||
const reordered = [...sectionIds]
|
||||
reordered.splice(oldIndex, 1)
|
||||
reordered.splice(newIndex, 0, active.id as string)
|
||||
|
||||
const updates = reordered.map((typeName, i) => ({ typeName, order: i }))
|
||||
onReorderSections?.(updates)
|
||||
const reordered = computeReorder(sectionIds, active.id as string, over.id as string)
|
||||
if (reordered) onReorderSections?.(reordered.map((typeName, i) => ({ typeName, order: i })))
|
||||
}, [sectionIds, onReorderSections])
|
||||
|
||||
const archivedCount = useMemo(() => entries.filter((e) => e.archived).length, [entries])
|
||||
const trashedCount = useMemo(() => entries.filter((e) => e.trashed).length, [entries])
|
||||
|
||||
// Close context menu on outside click
|
||||
useEffect(() => {
|
||||
if (!contextMenuPos) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
|
||||
setContextMenuPos(null)
|
||||
setContextMenuType(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [contextMenuPos])
|
||||
|
||||
// Close customize popover on outside click
|
||||
useEffect(() => {
|
||||
if (!customizeTarget) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
|
||||
setCustomizeTarget(null)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [customizeTarget])
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, type: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenuPos({ x: e.clientX, y: e.clientY })
|
||||
setContextMenuType(type)
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
setContextMenuPos({ x: e.clientX, y: e.clientY }); setContextMenuType(type)
|
||||
}, [])
|
||||
|
||||
const openCustomizePopover = useCallback((type: string) => {
|
||||
setContextMenuPos(null)
|
||||
setContextMenuType(null)
|
||||
setCustomizeTarget(type)
|
||||
}, [])
|
||||
|
||||
const handleCustomizeIcon = useCallback((icon: string) => {
|
||||
if (!customizeTarget) return
|
||||
const typeEntry = typeEntryMap[customizeTarget]
|
||||
if (typeEntry && onCustomizeType) {
|
||||
onCustomizeType(customizeTarget, icon, typeEntry.color ?? 'blue')
|
||||
}
|
||||
const handleCustomize = useCallback((prop: 'icon' | 'color', value: string) => {
|
||||
if (!customizeTarget || !onCustomizeType) return
|
||||
const te = typeEntryMap[customizeTarget]
|
||||
if (!te) return
|
||||
const [icon, color] = buildCustomizeArgs(te, prop, value)
|
||||
onCustomizeType(customizeTarget, icon, color)
|
||||
}, [customizeTarget, typeEntryMap, onCustomizeType])
|
||||
|
||||
const handleCustomizeColor = useCallback((color: string) => {
|
||||
if (!customizeTarget) return
|
||||
const typeEntry = typeEntryMap[customizeTarget]
|
||||
if (typeEntry && onCustomizeType) {
|
||||
onCustomizeType(customizeTarget, typeEntry.icon ?? 'file-text', color)
|
||||
}
|
||||
}, [customizeTarget, typeEntryMap, onCustomizeType])
|
||||
|
||||
const renderSectionContent = ({ label, type, Icon, customColor }: SectionGroup, dragHandleProps?: Record<string, unknown>) => {
|
||||
const items = entries.filter((e) => e.isA === type && !e.archived && !e.trashed)
|
||||
const isCollapsed = collapsed[type] ?? true
|
||||
const isTopic = type === 'Topic'
|
||||
const isTypeSection = type === 'Type'
|
||||
const sectionColor = getTypeColor(type, customColor)
|
||||
const sectionLightColor = getTypeLightColor(type, customColor)
|
||||
|
||||
const handlePlusClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isTypeSection) {
|
||||
onCreateNewType?.()
|
||||
} else {
|
||||
onCreateType?.(type)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Section header row */}
|
||||
<div
|
||||
className={cn(
|
||||
"group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors",
|
||||
isActive({ kind: 'sectionGroup', type })
|
||||
? "bg-secondary"
|
||||
: "hover:bg-accent"
|
||||
)}
|
||||
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
|
||||
onClick={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
onContextMenu={(e) => handleContextMenu(e, type)}
|
||||
>
|
||||
<div className="flex items-center" style={{ gap: 4 }}>
|
||||
{/* Drag handle */}
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab"
|
||||
style={{ width: 16, height: 16 }}
|
||||
{...dragHandleProps}
|
||||
aria-label={`Drag to reorder ${label}`}
|
||||
>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
<Icon size={16} style={{ color: sectionColor }} />
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 2 }}>
|
||||
{(onCreateType || (isTypeSection && onCreateNewType)) && (
|
||||
<button
|
||||
className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer"
|
||||
style={{ width: 20, height: 20 }}
|
||||
onClick={handlePlusClick}
|
||||
aria-label={isTypeSection ? 'Create new Type' : `Create new ${type}`}
|
||||
title={isTypeSection ? 'New Type' : `New ${type}`}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSection(type)
|
||||
}}
|
||||
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
|
||||
>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children items */}
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{items.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={cn(
|
||||
"cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors",
|
||||
isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent"
|
||||
)}
|
||||
style={{
|
||||
padding: '4px 16px 4px 28px',
|
||||
...(isActive(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry }) && {
|
||||
backgroundColor: sectionLightColor,
|
||||
color: sectionColor,
|
||||
}),
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelect(isTopic ? { kind: 'topic', entry } : { kind: 'entity', entry })
|
||||
onSelectNote?.(entry)
|
||||
}}
|
||||
>
|
||||
{entry.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SortableSection({ group }: { group: SectionGroup }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: group.type })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
padding: '4px 6px',
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
{renderSectionContent(group, listeners)}
|
||||
</div>
|
||||
)
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground" style={{ paddingTop: 38 } as React.CSSProperties}>
|
||||
{/* Native macOS title bar on top */}
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto">
|
||||
{/* Top nav — All Notes + Favorites */}
|
||||
{/* Top nav */}
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
{TOP_NAV.map(({ label, filter, Icon }) => {
|
||||
const count = filter === 'all' ? entries.filter((e) => !e.archived && !e.trashed).length : 0
|
||||
return (
|
||||
<div
|
||||
key={filter}
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
|
||||
isActive({ kind: 'filter', filter })
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent"
|
||||
)}
|
||||
style={{ padding: '6px 16px', borderRadius: 4 }}
|
||||
onClick={() => onSelect({ kind: 'filter', filter })}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">{label}</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
className="flex items-center justify-center bg-primary text-primary-foreground"
|
||||
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10 }}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{/* Archive filter */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
|
||||
isActive({ kind: 'filter', filter: 'archived' })
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-accent"
|
||||
)}
|
||||
style={{ padding: '6px 16px', borderRadius: 4 }}
|
||||
onClick={() => onSelect({ kind: 'filter', filter: 'archived' })}
|
||||
>
|
||||
<Archive size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">Archive</span>
|
||||
{archivedCount > 0 && (
|
||||
<span
|
||||
className="flex items-center justify-center text-muted-foreground"
|
||||
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
|
||||
>
|
||||
{archivedCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Disabled placeholders */}
|
||||
<div
|
||||
className="flex select-none items-center gap-2 rounded text-foreground"
|
||||
style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }}
|
||||
title="Coming soon"
|
||||
>
|
||||
<TagSimple size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">Untagged</span>
|
||||
</div>
|
||||
{/* Trash filter */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
|
||||
isActive({ kind: 'filter', filter: 'trash' })
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "text-foreground hover:bg-accent"
|
||||
)}
|
||||
style={{ padding: '6px 16px', borderRadius: 4 }}
|
||||
onClick={() => onSelect({ kind: 'filter', filter: 'trash' })}
|
||||
>
|
||||
<Trash size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">Trash</span>
|
||||
{trashedCount > 0 && (
|
||||
<span
|
||||
className="flex items-center justify-center text-muted-foreground"
|
||||
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
|
||||
>
|
||||
{trashedCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<NavItem icon={FileText} label="All Notes" count={activeCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'all' })} badgeClassName="bg-primary text-primary-foreground" onClick={() => onSelect({ kind: 'filter', filter: 'all' })} />
|
||||
<NavItem icon={Star} label="Favorites" isActive={isSelectionActive(selection, { kind: 'filter', filter: 'favorites' })} onClick={() => onSelect({ kind: 'filter', filter: 'favorites' })} />
|
||||
<NavItem icon={Archive} label="Archive" count={archivedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'archived' })} badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'archived' })} />
|
||||
<NavItem icon={TagSimple} label="Untagged" disabled />
|
||||
<NavItem icon={Trash} label="Trash" count={trashedCount} isActive={isSelectionActive(selection, { kind: 'filter', filter: 'trash' })} activeClassName="bg-destructive/10 text-destructive" badgeClassName="text-muted-foreground" badgeStyle={{ background: 'var(--muted)' }} onClick={() => onSelect({ kind: 'filter', filter: 'trash' })} />
|
||||
</div>
|
||||
|
||||
{/* Sections header + customize popover */}
|
||||
{/* Sections header + visibility popover */}
|
||||
<div ref={customizeRef} style={{ position: 'relative', padding: '4px 6px 0' }}>
|
||||
<div
|
||||
className="flex w-full select-none items-center justify-between"
|
||||
style={{ padding: '4px 16px' }}
|
||||
>
|
||||
<div className="flex w-full select-none items-center justify-between" style={{ padding: '4px 16px' }}>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Sections</span>
|
||||
<button
|
||||
className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground"
|
||||
style={{ width: 20, height: 20 }}
|
||||
onClick={() => setShowCustomize((v) => !v)}
|
||||
aria-label="Customize sections"
|
||||
title="Customize sections"
|
||||
>
|
||||
<button className="flex shrink-0 cursor-pointer items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground" style={{ width: 20, height: 20 }} onClick={() => setShowCustomize((v) => !v)} aria-label="Customize sections" title="Customize sections">
|
||||
<SlidersHorizontal size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showCustomize && (
|
||||
<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 rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>
|
||||
Show in sidebar
|
||||
</div>
|
||||
{allSectionGroups.map(({ label, type, Icon }) => (
|
||||
<button
|
||||
key={type}
|
||||
className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent"
|
||||
style={{ padding: '6px 12px', gap: 8 }}
|
||||
onClick={() => toggleVisibility(type)}
|
||||
aria-label={`Toggle ${label}`}
|
||||
>
|
||||
<Icon size={14} style={{ color: getTypeColor(type) }} />
|
||||
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
|
||||
<div
|
||||
className="flex items-center"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
padding: 2,
|
||||
backgroundColor: isSectionVisible(type) ? 'var(--primary)' : 'var(--muted)',
|
||||
justifyContent: isSectionVisible(type) ? 'flex-end' : 'flex-start',
|
||||
transition: 'background-color 150ms',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: 7,
|
||||
backgroundColor: 'white',
|
||||
transition: 'transform 150ms',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showCustomize && <VisibilityPopover sections={allSectionGroups} isSectionVisible={isSectionVisible} onToggle={toggleVisibility} />}
|
||||
</div>
|
||||
|
||||
{/* Section Groups (built-in + custom), filtered by visibility, sortable by drag */}
|
||||
{/* Sortable section groups */}
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={sectionIds} strategy={verticalListSortingStrategy}>
|
||||
{visibleSections.map((g) => (
|
||||
<SortableSection key={g.type} group={g} />
|
||||
<SortableSection key={g.type} group={g} sectionProps={sectionProps} />
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</nav>
|
||||
|
||||
{/* Commit button — always visible */}
|
||||
{onCommitPush && (
|
||||
<div className="shrink-0 border-t border-border" style={{ padding: 12 }}>
|
||||
<button
|
||||
className="flex w-full items-center justify-center bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
style={{ borderRadius: 6, gap: 6, padding: '8px 16px', border: 'none', cursor: 'pointer' }}
|
||||
onClick={onCommitPush}
|
||||
>
|
||||
<GitCommitHorizontal size={14} />
|
||||
<span className="text-[13px] font-medium">Commit & Push</span>
|
||||
{modifiedCount > 0 && (
|
||||
<span
|
||||
className="text-white font-semibold"
|
||||
style={{ background: '#ffffff40', borderRadius: 9, padding: '0 6px', fontSize: 10 }}
|
||||
>
|
||||
{modifiedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Context menu (right-click on section header) */}
|
||||
{contextMenuPos && contextMenuType && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed z-50 rounded-md border bg-popover p-1 shadow-md"
|
||||
style={{ left: contextMenuPos.x, top: contextMenuPos.y, minWidth: 180 }}
|
||||
>
|
||||
<button
|
||||
className="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"
|
||||
onClick={() => openCustomizePopover(contextMenuType)}
|
||||
>
|
||||
Customize icon & color…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Customize popover */}
|
||||
{customizeTarget && (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className="fixed z-50"
|
||||
style={{ left: 20, top: 100 }}
|
||||
>
|
||||
<TypeCustomizePopover
|
||||
currentIcon={typeEntryMap[customizeTarget]?.icon ?? null}
|
||||
currentColor={typeEntryMap[customizeTarget]?.color ?? null}
|
||||
onChangeIcon={handleCustomizeIcon}
|
||||
onChangeColor={handleCustomizeColor}
|
||||
onClose={() => setCustomizeTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CommitButton modifiedCount={modifiedCount} onClick={onCommitPush} />
|
||||
<ContextMenuOverlay pos={contextMenuPos} type={contextMenuType} innerRef={contextMenuRef} onOpenCustomize={(type) => { closeContextMenu(); setCustomizeTarget(type) }} />
|
||||
<CustomizeOverlay target={customizeTarget} typeEntryMap={typeEntryMap} innerRef={popoverRef} onCustomize={handleCustomize} onClose={closeCustomizeTarget} />
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
227
src/components/SidebarParts.tsx
Normal file
227
src/components/SidebarParts.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import { type ComponentType } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronRight, ChevronDown, Plus, GripVertical } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
|
||||
export interface SectionGroup {
|
||||
label: string
|
||||
type: string
|
||||
Icon: ComponentType<IconProps>
|
||||
customColor?: string | null
|
||||
}
|
||||
|
||||
export function isSelectionActive(current: SidebarSelection, check: SidebarSelection): boolean {
|
||||
if (current.kind !== check.kind) return false
|
||||
switch (check.kind) {
|
||||
case 'filter': return (current as typeof check).filter === check.filter
|
||||
case 'sectionGroup': return (current as typeof check).type === check.type
|
||||
case 'entity':
|
||||
case 'topic': return (current as typeof check).entry.path === check.entry.path
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- NavItem ---
|
||||
|
||||
export function NavItem({ icon: Icon, label, count, isActive, activeClassName = 'bg-primary/10 text-primary', badgeClassName, badgeStyle, onClick, disabled }: {
|
||||
icon: ComponentType<IconProps>
|
||||
label: string
|
||||
count?: number
|
||||
isActive?: boolean
|
||||
activeClassName?: string
|
||||
badgeClassName?: string
|
||||
badgeStyle?: React.CSSProperties
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
if (disabled) {
|
||||
return (
|
||||
<div className="flex select-none items-center gap-2 rounded text-foreground" style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }} title="Coming soon">
|
||||
<Icon size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("flex cursor-pointer select-none items-center gap-2 rounded transition-colors", isActive ? activeClassName : "text-foreground hover:bg-accent")}
|
||||
style={{ padding: '6px 16px', borderRadius: 4 }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">{label}</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className={cn("flex items-center justify-center", badgeClassName)} style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, ...badgeStyle }}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Section Content ---
|
||||
|
||||
export interface SectionContentProps {
|
||||
group: SectionGroup
|
||||
items: VaultEntry[]
|
||||
isCollapsed: boolean
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onSelectNote?: (entry: VaultEntry) => void
|
||||
onCreateType?: (type: string) => void
|
||||
onCreateNewType?: () => void
|
||||
onContextMenu: (e: React.MouseEvent, type: string) => void
|
||||
onToggle: () => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function childSelection(type: string, entry: VaultEntry): SidebarSelection {
|
||||
return type === 'Topic' ? { kind: 'topic', entry } : { kind: 'entity', entry }
|
||||
}
|
||||
|
||||
function resolveCreateHandler(type: string, onCreateType?: (type: string) => void, onCreateNewType?: () => void): (() => void) | undefined {
|
||||
const isType = type === 'Type'
|
||||
if (!onCreateType && !(isType && onCreateNewType)) return undefined
|
||||
return isType ? () => onCreateNewType?.() : () => onCreateType?.(type)
|
||||
}
|
||||
|
||||
export function SectionContent({
|
||||
group, items, isCollapsed, selection, onSelect, onSelectNote,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
|
||||
}: SectionContentProps) {
|
||||
const { label, type, Icon, customColor } = group
|
||||
const sectionColor = getTypeColor(type, customColor)
|
||||
const sectionLightColor = getTypeLightColor(type, customColor)
|
||||
const onCreate = resolveCreateHandler(type, onCreateType, onCreateNewType)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
label={label} type={type} Icon={Icon}
|
||||
sectionColor={sectionColor}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
|
||||
showCreate={!!onCreate}
|
||||
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
onToggle={onToggle}
|
||||
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
|
||||
dragHandleProps={dragHandleProps}
|
||||
/>
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<SectionChildList
|
||||
items={items} type={type} selection={selection}
|
||||
sectionColor={sectionColor} sectionLightColor={sectionLightColor}
|
||||
onSelect={onSelect} onSelectNote={onSelectNote}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildList({ items, type, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: {
|
||||
items: VaultEntry[]; type: string; selection: SidebarSelection
|
||||
sectionColor: string; sectionLightColor: string
|
||||
onSelect: (sel: SidebarSelection) => void; onSelectNote?: (entry: VaultEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{items.map((entry) => {
|
||||
const sel = childSelection(type, entry)
|
||||
const active = isSelectionActive(selection, sel)
|
||||
return (
|
||||
<SectionChildItem
|
||||
key={entry.path} title={entry.title} isActive={active}
|
||||
sectionColor={active ? sectionColor : undefined}
|
||||
sectionLightColor={active ? sectionLightColor : undefined}
|
||||
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps }: {
|
||||
label: string; type: string; Icon: ComponentType<IconProps>
|
||||
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
|
||||
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 6px', borderRadius: 4, gap: 4 }}
|
||||
onClick={onSelect} onContextMenu={onContextMenu}
|
||||
>
|
||||
<div className="flex items-center" style={{ gap: 4 }}>
|
||||
<div className="flex shrink-0 items-center justify-center text-muted-foreground opacity-0 group-hover/section:opacity-50 hover:!opacity-100 cursor-grab" style={{ width: 16, height: 16 }} {...dragHandleProps} aria-label={`Drag to reorder ${label}`}>
|
||||
<GripVertical size={12} />
|
||||
</div>
|
||||
<Icon size={16} style={{ color: sectionColor }} />
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 2 }}>
|
||||
{showCreate && (
|
||||
<button className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer" style={{ width: 20, height: 20 }} onClick={onCreate} aria-label={type === 'Type' ? 'Create new Type' : `Create new ${type}`} title={type === 'Type' ? 'New Type' : `New ${type}`}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer" onClick={(e) => { e.stopPropagation(); onToggle() }} aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildItem({ title, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; isActive: boolean
|
||||
sectionColor?: string; sectionLightColor?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors", isActive ? "text-foreground" : "text-muted-foreground hover:bg-accent")}
|
||||
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Visibility Popover ---
|
||||
|
||||
export function VisibilityPopover({ sections, isSectionVisible, onToggle }: {
|
||||
sections: SectionGroup[]
|
||||
isSectionVisible: (type: string) => boolean
|
||||
onToggle: (type: string) => void
|
||||
}) {
|
||||
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 rgba(0,0,0,0.12)' }}
|
||||
>
|
||||
<div className="text-[12px] font-semibold text-muted-foreground" style={{ padding: '0 12px 4px' }}>Show in sidebar</div>
|
||||
{sections.map(({ label, type, Icon }) => (
|
||||
<button key={type} className="flex w-full cursor-pointer items-center border-none bg-transparent transition-colors hover:bg-accent" style={{ padding: '6px 12px', gap: 8 }} onClick={() => onToggle(type)} aria-label={`Toggle ${label}`}>
|
||||
<Icon size={14} style={{ color: getTypeColor(type) }} />
|
||||
<span className="flex-1 text-left text-[13px] text-foreground">{label}</span>
|
||||
<ToggleSwitch on={isSectionVisible(type)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleSwitch({ on }: { on: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center" style={{ width: 32, height: 18, borderRadius: 9, padding: 2, backgroundColor: on ? 'var(--primary)' : 'var(--muted)', justifyContent: on ? 'flex-end' : 'flex-start', transition: 'background-color 150ms' }}>
|
||||
<div style={{ width: 14, height: 14, borderRadius: 7, backgroundColor: 'white', transition: 'transform 150ms' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -20,191 +20,177 @@ interface TabBarProps {
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
|
||||
export const TabBar = memo(function TabBar({
|
||||
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs,
|
||||
}: TabBarProps) {
|
||||
// --- Drag-and-drop helpers ---
|
||||
|
||||
function computeDropTarget(dragIdx: number | null, dropIdx: number | null): number | null {
|
||||
if (dragIdx === null || dropIdx === null || dragIdx === dropIdx) return null
|
||||
const toIndex = dropIdx > dragIdx ? dropIdx - 1 : dropIdx
|
||||
return toIndex !== dragIdx ? toIndex : null
|
||||
}
|
||||
|
||||
function computeInsertIndex(e: React.DragEvent<HTMLDivElement>, index: number): number {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
return e.clientX < rect.left + rect.width / 2 ? index : index + 1
|
||||
}
|
||||
|
||||
function useTabDrag(onReorderTabs?: (from: number, to: number) => void) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [dropIndex, setDropIndex] = useState<number | null>(null)
|
||||
const dragNodeRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
setDragIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(index))
|
||||
// Make the drag image slightly transparent
|
||||
if (e.currentTarget) {
|
||||
dragNodeRef.current = e.currentTarget
|
||||
requestAnimationFrame(() => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.style.opacity = '0.5'
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
if (dragNodeRef.current) {
|
||||
dragNodeRef.current.style.opacity = ''
|
||||
}
|
||||
const resetDrag = useCallback(() => {
|
||||
if (dragNodeRef.current) dragNodeRef.current.style.opacity = ''
|
||||
dragNodeRef.current = null
|
||||
setDragIndex(null)
|
||||
setDropIndex(null)
|
||||
}, [])
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
setDragIndex(index)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
e.dataTransfer.setData('text/plain', String(index))
|
||||
dragNodeRef.current = e.currentTarget
|
||||
requestAnimationFrame(() => {
|
||||
if (dragNodeRef.current) dragNodeRef.current.style.opacity = '0.5'
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>, index: number) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
if (dragIndex === null || dragIndex === index) {
|
||||
setDropIndex(null)
|
||||
return
|
||||
}
|
||||
// Determine drop position based on cursor within the tab
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const midpoint = rect.left + rect.width / 2
|
||||
const insertIndex = e.clientX < midpoint ? index : index + 1
|
||||
setDropIndex(insertIndex)
|
||||
if (dragIndex === null || dragIndex === index) { setDropIndex(null); return }
|
||||
setDropIndex(computeInsertIndex(e, index))
|
||||
}, [dragIndex])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dropIndex !== null && dragIndex !== dropIndex && onReorderTabs) {
|
||||
// Adjust target index: if dropping after the dragged item, account for removal
|
||||
const toIndex = dropIndex > dragIndex ? dropIndex - 1 : dropIndex
|
||||
if (toIndex !== dragIndex) {
|
||||
onReorderTabs(dragIndex, toIndex)
|
||||
}
|
||||
}
|
||||
handleDragEnd()
|
||||
}, [dragIndex, dropIndex, onReorderTabs, handleDragEnd])
|
||||
const toIndex = computeDropTarget(dragIndex, dropIndex)
|
||||
if (toIndex !== null && onReorderTabs) onReorderTabs(dragIndex!, toIndex)
|
||||
resetDrag()
|
||||
}, [dragIndex, dropIndex, onReorderTabs, resetDrag])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
// Only clear if we're leaving the tab bar entirely
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
if (!e.currentTarget.contains(relatedTarget)) {
|
||||
setDropIndex(null)
|
||||
}
|
||||
const handleBarDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
const related = e.relatedTarget as HTMLElement | null
|
||||
if (!e.currentTarget.contains(related)) setDropIndex(null)
|
||||
}, [])
|
||||
|
||||
return { dragIndex, dropIndex, handleDragStart, handleDragEnd: resetDrag, handleDragOver, handleDrop, handleBarDragLeave }
|
||||
}
|
||||
|
||||
// --- Sub-components ---
|
||||
|
||||
function DropIndicator({ side }: { side: 'left' | 'right' }) {
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', [side]: -1, top: 8, bottom: 8,
|
||||
width: 2, background: 'var(--primary)', borderRadius: 1, zIndex: 10,
|
||||
}} />
|
||||
)
|
||||
}
|
||||
|
||||
function TabItem({ tab, isActive, isDragging, showDropBefore, showDropAfter, onSwitch, onClose, dragProps }: {
|
||||
tab: Tab
|
||||
isActive: boolean
|
||||
isDragging: boolean
|
||||
showDropBefore: boolean
|
||||
showDropAfter: boolean
|
||||
onSwitch: () => void
|
||||
onClose: () => void
|
||||
dragProps: React.HTMLAttributes<HTMLDivElement>
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
{...dragProps}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative",
|
||||
isActive ? "text-foreground" : "text-muted-foreground hover:text-secondary-foreground"
|
||||
)}
|
||||
style={{
|
||||
background: isActive ? 'var(--background)' : 'transparent',
|
||||
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
|
||||
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
|
||||
padding: '0 12px', fontSize: 12,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
} as React.CSSProperties}
|
||||
onClick={onSwitch}
|
||||
>
|
||||
{showDropBefore && <DropIndicator side="left" />}
|
||||
<span className="truncate">{tab.entry.title}</span>
|
||||
<button
|
||||
className={cn(
|
||||
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
|
||||
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
style={{ lineHeight: 0 }}
|
||||
draggable={false}
|
||||
onClick={(e) => { e.stopPropagation(); onClose() }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{showDropAfter && <DropIndicator side="right" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabBarActions({ onCreateNote }: { onCreateNote?: () => void }) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center"
|
||||
style={{
|
||||
borderLeft: '1px solid var(--border)', borderBottom: '1px solid var(--border)',
|
||||
gap: 12, padding: '0 12px', WebkitAppRegion: 'no-drag',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors" onClick={onCreateNote} title="New note">
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground" style={DISABLED_ICON_STYLE} title="Coming soon" tabIndex={-1}>
|
||||
<Columns size={16} />
|
||||
</button>
|
||||
<button className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground" style={DISABLED_ICON_STYLE} title="Coming soon" tabIndex={-1}>
|
||||
<ArrowsOutSimple size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main TabBar ---
|
||||
|
||||
export const TabBar = memo(function TabBar({
|
||||
tabs, activeTabPath, onSwitchTab, onCloseTab, onCreateNote, onReorderTabs,
|
||||
}: TabBarProps) {
|
||||
const { dragIndex, dropIndex, handleDragStart, handleDragEnd, handleDragOver, handleDrop, handleBarDragLeave } = useTabDrag(onReorderTabs)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-stretch"
|
||||
style={{ height: 45, background: 'var(--sidebar)', WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
data-tauri-drag-region
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragLeave={handleBarDragLeave}
|
||||
>
|
||||
{tabs.map((tab, index) => {
|
||||
const isActive = tab.entry.path === activeTabPath
|
||||
const showDropBefore = dropIndex === index
|
||||
const showDropAfter = dropIndex === index + 1 && index === tabs.length - 1
|
||||
return (
|
||||
<div
|
||||
key={tab.entry.path}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
"group flex shrink-0 items-center gap-1.5 whitespace-nowrap max-w-[180px] transition-all relative",
|
||||
isActive
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-secondary-foreground"
|
||||
)}
|
||||
style={{
|
||||
background: isActive ? 'var(--background)' : 'transparent',
|
||||
borderRight: `1px solid ${isActive ? 'var(--border)' : 'var(--sidebar-border)'}`,
|
||||
borderBottom: isActive ? 'none' : '1px solid var(--sidebar-border)',
|
||||
padding: '0 12px',
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? 500 : 400,
|
||||
cursor: dragIndex !== null ? 'grabbing' : 'grab',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
} as React.CSSProperties}
|
||||
onClick={() => onSwitchTab(tab.entry.path)}
|
||||
>
|
||||
{showDropBefore && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: -1,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: 2,
|
||||
background: 'var(--primary)',
|
||||
borderRadius: 1,
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{tab.entry.title}</span>
|
||||
<button
|
||||
className={cn(
|
||||
"shrink-0 rounded-sm p-0 bg-transparent border-none text-muted-foreground cursor-pointer transition-opacity hover:bg-accent hover:text-foreground",
|
||||
isActive ? "opacity-100" : "opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
style={{ lineHeight: 0 }}
|
||||
draggable={false}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onCloseTab(tab.entry.path)
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{showDropAfter && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -1,
|
||||
top: 8,
|
||||
bottom: 8,
|
||||
width: 2,
|
||||
background: 'var(--primary)',
|
||||
borderRadius: 1,
|
||||
zIndex: 10,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{tabs.map((tab, index) => (
|
||||
<TabItem
|
||||
key={tab.entry.path}
|
||||
tab={tab}
|
||||
isActive={tab.entry.path === activeTabPath}
|
||||
isDragging={dragIndex !== null}
|
||||
showDropBefore={dropIndex === index}
|
||||
showDropAfter={dropIndex === index + 1 && index === tabs.length - 1}
|
||||
onSwitch={() => onSwitchTab(tab.entry.path)}
|
||||
onClose={() => onCloseTab(tab.entry.path)}
|
||||
dragProps={{
|
||||
onDragStart: (e) => handleDragStart(e, index),
|
||||
onDragEnd: handleDragEnd,
|
||||
onDragOver: (e) => handleDragOver(e, index),
|
||||
onDrop: handleDrop,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div className="flex-1" style={{ borderBottom: '1px solid var(--border)' }} />
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center"
|
||||
style={{
|
||||
borderLeft: '1px solid var(--border)',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
gap: 12,
|
||||
padding: '0 12px',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
onClick={onCreateNote}
|
||||
title="New note"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
title="Coming soon"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<Columns size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground"
|
||||
style={DISABLED_ICON_STYLE}
|
||||
title="Coming soon"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ArrowsOutSimple size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<TabBarActions onCreateNote={onCreateNote} />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user