diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts index 200535d3..c79cbd75 100644 --- a/e2e/app.spec.ts +++ b/e2e/app.spec.ts @@ -10,9 +10,13 @@ test('app loads with four-panel layout', async ({ page }) => { await expect(page.locator('.inspector')).toBeVisible() }) -test('sidebar shows navigation items', async ({ page }) => { +test('sidebar shows section groups', async ({ page }) => { await page.goto('/') + await page.waitForTimeout(500) // Wait for mock data - await expect(page.getByText('Laputa')).toBeVisible() - await expect(page.getByText('All Notes')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Laputa' })).toBeVisible() + await expect(page.getByText('PROJECTS')).toBeVisible() + await expect(page.getByText('EXPERIMENTS')).toBeVisible() + await expect(page.getByText('RESPONSIBILITIES')).toBeVisible() + await expect(page.getByText('PROCEDURES')).toBeVisible() }) diff --git a/src/App.tsx b/src/App.tsx index d8f3f901..fd360e53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,14 +6,17 @@ import { Editor } from './components/Editor' import { Inspector } from './components/Inspector' import { ResizeHandle } from './components/ResizeHandle' import { isTauri, mockInvoke } from './mock-tauri' -import type { VaultEntry } from './types' +import type { VaultEntry, SidebarSelection } from './types' import './App.css' // TODO: Make vault path configurable via settings const TEST_VAULT_PATH = '~/Laputa' +const DEFAULT_SELECTION: SidebarSelection = { kind: 'filter', filter: 'all' } + function App() { const [entries, setEntries] = useState([]) + const [selection, setSelection] = useState(DEFAULT_SELECTION) const [sidebarWidth, setSidebarWidth] = useState(250) const [noteListWidth, setNoteListWidth] = useState(300) const [inspectorWidth, setInspectorWidth] = useState(280) @@ -56,7 +59,7 @@ function App() { return (
- +
diff --git a/src/components/Sidebar.css b/src/components/Sidebar.css index 83039114..83634ff5 100644 --- a/src/components/Sidebar.css +++ b/src/components/Sidebar.css @@ -4,11 +4,13 @@ display: flex; flex-direction: column; overflow-y: auto; + height: 100%; } .sidebar__header { padding: 16px; border-bottom: 1px solid #2a2a4a; + flex-shrink: 0; } .sidebar__header h2 { @@ -20,14 +22,97 @@ .sidebar__nav { padding: 8px 0; + flex: 1; + overflow-y: auto; +} + +/* Section Groups */ +.sidebar__section { + margin-bottom: 4px; +} + +.sidebar__section-header { + display: flex; + align-items: center; + padding: 6px 8px; + margin: 0 4px; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.05em; + color: #888; + user-select: none; +} + +.sidebar__section-header:hover { + background: #2a2a4a; + color: #bbb; +} + +.sidebar__section-header--active { + background: #2a2a4a; + color: #ffffff; +} + +.sidebar__collapse-btn { + background: none; + border: none; + color: inherit; + font-size: 10px; + cursor: pointer; + padding: 0 4px 0 0; + line-height: 1; + width: 16px; + flex-shrink: 0; +} + +.sidebar__section-label { + flex: 1; +} + +.sidebar__section-count { + font-size: 10px; + color: #666; + margin-right: 4px; +} + +.sidebar__add-btn { + background: none; + border: none; + color: #666; + font-size: 14px; + cursor: pointer; + padding: 0 2px; + line-height: 1; + border-radius: 3px; + opacity: 0; + transition: opacity 0.15s; +} + +.sidebar__section-header:hover .sidebar__add-btn { + opacity: 1; +} + +.sidebar__add-btn:hover { + color: #ffffff; + background: #3a3a5a; +} + +/* Section items (entity list) */ +.sidebar__section-items { + padding: 2px 0; } .sidebar__item { - padding: 8px 16px; + padding: 5px 16px 5px 28px; cursor: pointer; - font-size: 14px; + font-size: 13px; border-radius: 4px; - margin: 2px 8px; + margin: 1px 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .sidebar__item:hover { diff --git a/src/components/Sidebar.test.tsx b/src/components/Sidebar.test.tsx index b6739c1f..1fca7f6d 100644 --- a/src/components/Sidebar.test.tsx +++ b/src/components/Sidebar.test.tsx @@ -1,18 +1,128 @@ -import { render, screen } from '@testing-library/react' -import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' import { Sidebar } from './Sidebar' +import type { VaultEntry, SidebarSelection } from '../types' + +const mockEntries: VaultEntry[] = [ + { + path: '/vault/project/build-app.md', + filename: 'build-app.md', + title: 'Build Laputa App', + isA: 'Project', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + owner: 'Luca', + cadence: null, + modifiedAt: 1700000000, + fileSize: 1024, + }, + { + path: '/vault/responsibility/grow-newsletter.md', + filename: 'grow-newsletter.md', + title: 'Grow Newsletter', + isA: 'Responsibility', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + owner: 'Luca', + cadence: null, + modifiedAt: 1700000000, + fileSize: 512, + }, + { + path: '/vault/experiment/stock-screener.md', + filename: 'stock-screener.md', + title: 'Stock Screener', + isA: 'Experiment', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + owner: 'Luca', + cadence: null, + modifiedAt: 1700000000, + fileSize: 256, + }, + { + path: '/vault/procedure/weekly-essays.md', + filename: 'weekly-essays.md', + title: 'Write Weekly Essays', + isA: 'Procedure', + aliases: [], + belongsTo: [], + relatedTo: [], + status: 'Active', + owner: 'Luca', + cadence: 'Weekly', + modifiedAt: 1700000000, + fileSize: 128, + }, +] + +const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' } describe('Sidebar', () => { it('renders the app title', () => { - render() + render( {}} />) expect(screen.getByText('Laputa')).toBeInTheDocument() }) - it('renders navigation items', () => { - render() - expect(screen.getByText('All Notes')).toBeInTheDocument() - expect(screen.getByText('Projects')).toBeInTheDocument() - expect(screen.getByText('Tasks')).toBeInTheDocument() - expect(screen.getByText('People')).toBeInTheDocument() + it('renders section group headers', () => { + render( {}} />) + expect(screen.getByText('PROJECTS')).toBeInTheDocument() + expect(screen.getByText('EXPERIMENTS')).toBeInTheDocument() + expect(screen.getByText('RESPONSIBILITIES')).toBeInTheDocument() + expect(screen.getByText('PROCEDURES')).toBeInTheDocument() + }) + + it('shows entity names under their section groups', () => { + render( {}} />) + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + expect(screen.getByText('Grow Newsletter')).toBeInTheDocument() + expect(screen.getByText('Stock Screener')).toBeInTheDocument() + expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument() + }) + + it('shows correct counts per section', () => { + render( {}} />) + // Each section has exactly 1 item + const counts = screen.getAllByText('1') + expect(counts.length).toBe(4) + }) + + it('collapses and expands sections', () => { + render( {}} />) + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + + // Collapse PROJECTS section + fireEvent.click(screen.getByLabelText('Collapse PROJECTS')) + expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument() + + // Expand PROJECTS section + fireEvent.click(screen.getByLabelText('Expand PROJECTS')) + expect(screen.getByText('Build Laputa App')).toBeInTheDocument() + }) + + it('calls onSelect when clicking an entity', () => { + const onSelect = vi.fn() + render() + fireEvent.click(screen.getByText('Build Laputa App')) + expect(onSelect).toHaveBeenCalledWith({ + kind: 'entity', + entry: mockEntries[0], + }) + }) + + it('calls onSelect when clicking a section header', () => { + const onSelect = vi.fn() + render() + fireEvent.click(screen.getByText('PROJECTS')) + expect(onSelect).toHaveBeenCalledWith({ + kind: 'sectionGroup', + type: 'Project', + }) }) }) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 8781ee86..c33008f4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,16 +1,96 @@ +import { useState } from 'react' +import type { VaultEntry, SidebarSelection } from '../types' import './Sidebar.css' -export function Sidebar() { +interface SidebarProps { + entries: VaultEntry[] + selection: SidebarSelection + onSelect: (selection: SidebarSelection) => void +} + +const SECTION_GROUPS = [ + { label: 'PROJECTS', type: 'Project' }, + { label: 'EXPERIMENTS', type: 'Experiment' }, + { label: 'RESPONSIBILITIES', type: 'Responsibility' }, + { label: 'PROCEDURES', type: 'Procedure' }, +] as const + +export function Sidebar({ entries, selection, onSelect }: SidebarProps) { + const [collapsed, setCollapsed] = useState>({}) + + const toggleSection = (type: string) => { + setCollapsed((prev) => ({ ...prev, [type]: !prev[type] })) + } + + 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 + } + return ( ) diff --git a/src/types.ts b/src/types.ts index b82469c1..bce1a16f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,3 +12,9 @@ export interface VaultEntry { modifiedAt: number | null fileSize: number } + +export type SidebarSelection = + | { kind: 'filter'; filter: 'all' | 'people' | 'events' | 'favorites' | 'trash' } + | { kind: 'sectionGroup'; type: string } + | { kind: 'entity'; entry: VaultEntry } + | { kind: 'topic'; entry: VaultEntry }