diff --git a/e2e/filtering.spec.ts b/e2e/filtering.spec.ts
index aa1fe65e..b80aeea5 100644
--- a/e2e/filtering.spec.ts
+++ b/e2e/filtering.spec.ts
@@ -54,3 +54,13 @@ test('clicking topic shows entries related to that topic', async ({ page }) => {
await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).toBeVisible()
await page.screenshot({ path: 'test-results/filter-topic.png' })
})
+
+test('search bar filters by title substring', async ({ page }) => {
+ await page.fill('.note-list__search-input', 'budget')
+ await page.waitForTimeout(100)
+ const count = page.locator('.note-list__count')
+ await expect(count).toHaveText('1')
+ await expect(page.locator('.note-list__title', { hasText: 'Budget Allocation' })).toBeVisible()
+ await expect(page.locator('.note-list__title', { hasText: 'Build Laputa App' })).not.toBeVisible()
+ await page.screenshot({ path: 'test-results/search-budget.png' })
+})
diff --git a/src/components/NoteList.css b/src/components/NoteList.css
index 76102c8b..adb10c65 100644
--- a/src/components/NoteList.css
+++ b/src/components/NoteList.css
@@ -29,6 +29,32 @@
border-radius: 10px;
}
+/* Search */
+.note-list__search {
+ padding: 8px 12px;
+ border-bottom: 1px solid #2a2a4a;
+}
+
+.note-list__search-input {
+ width: 100%;
+ padding: 6px 10px;
+ background: #1e1e3a;
+ border: 1px solid #2a2a4a;
+ border-radius: 6px;
+ color: #e0e0e0;
+ font-size: 13px;
+ outline: none;
+ box-sizing: border-box;
+}
+
+.note-list__search-input::placeholder {
+ color: #666;
+}
+
+.note-list__search-input:focus {
+ border-color: #4a4a7a;
+}
+
.note-list__items {
flex: 1;
}
diff --git a/src/components/NoteList.test.tsx b/src/components/NoteList.test.tsx
index b23ed01d..6584c057 100644
--- a/src/components/NoteList.test.tsx
+++ b/src/components/NoteList.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react'
+import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { NoteList } from './NoteList'
import type { VaultEntry, SidebarSelection } from '../types'
@@ -132,4 +132,32 @@ describe('NoteList', () => {
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
expect(screen.queryByText('Facebook Ads Strategy')).not.toBeInTheDocument()
})
+
+ it('renders a search bar', () => {
+ render()
+ expect(screen.getByPlaceholderText('Search notes...')).toBeInTheDocument()
+ })
+
+ it('filters by search query (case-insensitive substring)', () => {
+ render()
+ const input = screen.getByPlaceholderText('Search notes...')
+ fireEvent.change(input, { target: { value: 'facebook' } })
+ expect(screen.getByText('Facebook Ads Strategy')).toBeInTheDocument()
+ expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
+ expect(screen.getByText('1')).toBeInTheDocument()
+ })
+
+ it('sorts entries by last modified descending', () => {
+ const entriesWithDifferentDates: VaultEntry[] = [
+ { ...mockEntries[0], modifiedAt: 1000, title: 'Oldest' },
+ { ...mockEntries[1], modifiedAt: 3000, title: 'Newest', path: '/p2' },
+ { ...mockEntries[2], modifiedAt: 2000, title: 'Middle', path: '/p3' },
+ ]
+ render()
+ const titles = screen.getAllByClassName
+ ? [] // fallback
+ : screen.getAllByText(/Oldest|Newest|Middle/)
+ const titleTexts = titles.map((el) => el.textContent)
+ expect(titleTexts).toEqual(['Newest', 'Middle', 'Oldest'])
+ })
})
diff --git a/src/components/NoteList.tsx b/src/components/NoteList.tsx
index 6e168d0b..0038457e 100644
--- a/src/components/NoteList.tsx
+++ b/src/components/NoteList.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react'
import type { VaultEntry, SidebarSelection } from '../types'
import './NoteList.css'
@@ -50,20 +51,50 @@ function filterEntries(entries: VaultEntry[], selection: SidebarSelection): Vaul
}
}
+function sortByModified(a: VaultEntry, b: VaultEntry): number {
+ return (b.modifiedAt ?? 0) - (a.modifiedAt ?? 0)
+}
+
export function NoteList({ entries, selection }: NoteListProps) {
+ const [search, setSearch] = useState('')
+
const filtered = filterEntries(entries, selection)
+ // Sort: for entity view, keep pinned first, sort children; otherwise sort all
+ let sorted: VaultEntry[]
+ if (selection.kind === 'entity' && filtered.length > 0) {
+ const [pinned, ...children] = filtered
+ sorted = [pinned, ...children.sort(sortByModified)]
+ } else {
+ sorted = [...filtered].sort(sortByModified)
+ }
+
+ // Search filter (title substring, case-insensitive)
+ const query = search.trim().toLowerCase()
+ const displayed = query
+ ? sorted.filter((e) => e.title.toLowerCase().includes(query))
+ : sorted
+
return (
Notes
- {filtered.length}
+ {displayed.length}
+
+
+ setSearch(e.target.value)}
+ />
- {filtered.length === 0 ? (
+ {displayed.length === 0 ? (
No notes found
) : (
- filtered.map((entry, i) => (
+ displayed.map((entry, i) => (