diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index 4e6d430..591f33c 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -747,7 +747,7 @@ export class PostEngine extends EventEmitter { // Search the stemmed content, filtered by project_id for project isolation const result = await client.execute({ - sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 50`, + sql: `SELECT id FROM posts_fts WHERE project_id = ? AND posts_fts MATCH ? ORDER BY rank LIMIT 500`, args: [this.currentProjectId, stemmedQuery], }); diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index 50159c3..2b9cf0a 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -483,12 +483,14 @@ type PostsListMode = 'posts' | 'pages'; interface PostsListProps { mode: PostsListMode; + isActive: boolean; } -const PostsList: React.FC = ({ mode }) => { +const PostsList: React.FC = ({ mode, isActive }) => { const { posts, hasMorePosts, totalPosts, appendPosts, openTab, activeTabId } = useAppStore(); const isPagesMode = mode === 'pages'; const postSubset = useMemo(() => applyPageFilter(posts, isPagesMode), [posts, isPagesMode]); + const [pageBasePosts, setPageBasePosts] = useState(null); // Filter state const [searchQuery, setSearchQuery] = useState(''); @@ -534,6 +536,35 @@ const PostsList: React.FC = ({ mode }) => { loadFilters(); }, [posts]); + // In pages mode, load the full pages subset from backend filtering, + // independent of currently paged post list in the store. + useEffect(() => { + if (!isPagesMode || !isActive) { + return; + } + + let isCancelled = false; + + const loadPagesBase = async () => { + try { + const results = await window.electronAPI?.posts.filter({ categories: [PAGE_CATEGORY] }); + if (!isCancelled && results) { + setPageBasePosts(results as PostData[]); + } + } catch (error) { + if (!isCancelled) { + console.error('Failed to load pages subset:', error); + } + } + }; + + loadPagesBase(); + + return () => { + isCancelled = true; + }; + }, [isPagesMode, isActive, posts]); + // Handle search const handleSearch = async (query: string) => { setSearchQuery(query); @@ -725,15 +756,17 @@ const PostsList: React.FC = ({ mode }) => { // Determine which posts to display // Filters only apply to published/archived posts — drafts are always shown unfiltered - const filteredDisplayPosts = searchResults ?? filteredPosts ?? null; + const filteredDisplayPosts = searchResults ?? filteredPosts ?? (isPagesMode ? pageBasePosts : null); const isFiltered = filteredDisplayPosts !== null; const hasActiveFilters = searchQuery || selectedYear || selectedTags.length > 0 || selectedCategories.length > 0; + const baseDisplayPosts = isPagesMode ? (pageBasePosts ?? postSubset) : postSubset; + // Memoized grouping that freshens cached filter results with current store data // This ensures status changes are reflected even when filters are active const groupedPosts = useMemo( - () => groupPostsByStatus(postSubset, filteredDisplayPosts), - [postSubset, filteredDisplayPosts] + () => groupPostsByStatus(baseDisplayPosts, filteredDisplayPosts), + [baseDisplayPosts, filteredDisplayPosts] ); const clearAllFilters = () => { @@ -896,7 +929,7 @@ const PostsList: React.FC = ({ mode }) => { )} - {postSubset.length === 0 && !isFiltered && ( + {baseDisplayPosts.length === 0 && !isFiltered && (

{isPagesMode ? 'No pages yet' : 'No posts yet'}

@@ -1582,11 +1615,25 @@ export const Sidebar: React.FC = () => { return (
-
- +
+
-
- +
+
{activeView === 'media' && } {activeView === 'settings' && } diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index 2a30828..44216b8 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -2175,6 +2175,19 @@ Published content`); const result = await postEngine.searchPosts('nonexistent'); expect(result).toEqual([]); }); + + it('should cap search results at 500', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + await postEngine.searchPosts('term'); + + expect(mockLocalClient.execute).toHaveBeenCalled(); + const call = vi.mocked(mockLocalClient.execute).mock.calls[0]?.[0] as { sql?: string } | undefined; + expect(call?.sql).toBeDefined(); + const sql = call?.sql?.toLowerCase() ?? ''; + expect(sql).toContain('limit 500'); + expect(sql).not.toMatch(/\blimit\s+50\b/); + }); }); describe('getTagsWithCounts', () => { diff --git a/tests/renderer/components/PagesShortcut.test.tsx b/tests/renderer/components/PagesShortcut.test.tsx index 4659fd1..c3296a1 100644 --- a/tests/renderer/components/PagesShortcut.test.tsx +++ b/tests/renderer/components/PagesShortcut.test.tsx @@ -77,6 +77,9 @@ describe('Pages shortcut UI', () => { it('shows only page-category posts when pages view is active', async () => { useAppStore.setState({ activeView: 'pages', sidebarVisible: true }); + window.electronAPI.posts.filter = vi.fn().mockResolvedValue([ + createMockPost({ id: 'post-page', title: 'About Page', categories: ['page'] }), + ]); render(); @@ -87,4 +90,70 @@ describe('Pages shortcut UI', () => { expect(within(pagesPanel as HTMLElement).getByText('About Page')).toBeInTheDocument(); expect(within(pagesPanel as HTMLElement).queryByText('Regular Article')).not.toBeInTheDocument(); }); + + it('loads pages subset from full table and does not require load-more pagination', async () => { + useAppStore.setState({ + activeView: 'pages', + sidebarVisible: true, + posts: [ + createMockPost({ + id: 'post-article-only', + title: 'Loaded Article', + categories: ['article'], + }), + ], + hasMorePosts: true, + totalPosts: 1200, + }); + + window.electronAPI.posts.filter = vi.fn().mockResolvedValue([ + createMockPost({ id: 'post-page-remote', title: 'Remote Page', categories: ['page'] }), + ]); + + render(); + + const pagesHeader = await screen.findByText('PAGES'); + const pagesPanel = pagesHeader.closest('.sidebar-content') as HTMLElement; + + expect(within(pagesPanel).getByText('Remote Page')).toBeInTheDocument(); + expect(within(pagesPanel).queryByText('Loaded Article')).not.toBeInTheDocument(); + expect(within(pagesPanel).queryByText(/Load more/i)).not.toBeInTheDocument(); + expect(window.electronAPI.posts.filter).toHaveBeenCalledWith({ categories: ['page'] }); + }); + + it('does not prefetch pages subset while posts view is active', async () => { + useAppStore.setState({ + activeView: 'posts', + sidebarVisible: true, + posts: [ + createMockPost({ id: 'post-1', title: 'Loaded Post', categories: ['article'] }), + ], + hasMorePosts: true, + totalPosts: 1200, + }); + + window.electronAPI.posts.filter = vi.fn().mockResolvedValue([ + createMockPost({ id: 'post-page-remote', title: 'Remote Page', categories: ['page'] }), + ]); + + render(); + + expect(await screen.findByText('POSTS')).toBeInTheDocument(); + expect(window.electronAPI.posts.filter).not.toHaveBeenCalledWith({ categories: ['page'] }); + }); + + it('uses a flex-height wrapper for active posts/pages sidebar view', async () => { + useAppStore.setState({ + activeView: 'posts', + sidebarVisible: true, + posts: [createMockPost({ id: 'post-1', title: 'Loaded Post', categories: ['article'] })], + }); + + const { container } = render(); + expect(await screen.findByText('POSTS')).toBeInTheDocument(); + + const wrappers = container.querySelectorAll('.sidebar > div'); + expect(wrappers.length).toBeGreaterThanOrEqual(2); + expect((wrappers[0] as HTMLElement).style.display).toBe('flex'); + }); }); \ No newline at end of file