From 156bba335978617ba4fd052a974223cc66140f95 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Tue, 9 Dec 2025 17:48:27 +0200 Subject: [PATCH] Infinite scroll lazy load document list in inspector --- .../rag/inspector/widgets/document_list.py | 28 ++++- tests/test_inspector.py | 116 +++++++++++++++++- 2 files changed, 141 insertions(+), 3 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/inspector/widgets/document_list.py b/haiku_rag_slim/haiku/rag/inspector/widgets/document_list.py index 9ec2e8af..e83d8286 100644 --- a/haiku_rag_slim/haiku/rag/inspector/widgets/document_list.py +++ b/haiku_rag_slim/haiku/rag/inspector/widgets/document_list.py @@ -7,6 +7,8 @@ from textual.widgets import ListItem, ListView, Static from haiku.rag.client import HaikuRAG from haiku.rag.store.models import Document +BATCH_SIZE = 50 + class DocumentList(VerticalScroll): # pragma: no cover """Widget for displaying and browsing documents.""" @@ -24,6 +26,9 @@ class DocumentList(VerticalScroll): # pragma: no cover super().__init__(**kwargs) self.documents: list[Document] = [] self.list_view = ListView() + self.has_more: bool = True + self._client: HaikuRAG | None = None + self._loading: bool = False def compose(self) -> ComposeResult: """Compose the document list.""" @@ -31,13 +36,29 @@ class DocumentList(VerticalScroll): # pragma: no cover yield self.list_view async def load_documents(self, client: HaikuRAG) -> None: - """Load all documents from the database.""" - self.documents = await client.list_documents(limit=None) + """Load initial batch of documents from the database.""" + self._client = client + self.documents = await client.list_documents(limit=BATCH_SIZE, offset=0) + self.has_more = len(self.documents) >= BATCH_SIZE await self.list_view.clear() for doc in self.documents: title = doc.title or doc.uri or doc.id await self.list_view.append(ListItem(Static(f"{title}"))) + async def load_more(self, client: HaikuRAG) -> None: + """Load the next batch of documents.""" + if not self.has_more or self._loading: + return + self._loading = True + offset = len(self.documents) + new_docs = await client.list_documents(limit=BATCH_SIZE, offset=offset) + self.has_more = len(new_docs) >= BATCH_SIZE + self.documents.extend(new_docs) + for doc in new_docs: + title = doc.title or doc.uri or doc.id + await self.list_view.append(ListItem(Static(f"{title}"))) + self._loading = False + @on(ListView.Highlighted) @on(ListView.Selected) async def handle_document_selection( @@ -49,3 +70,6 @@ class DocumentList(VerticalScroll): # pragma: no cover idx = event.list_view.index if idx is not None and 0 <= idx < len(self.documents): self.post_message(self.DocumentSelected(self.documents[idx])) + # Infinite scroll: load more when near the end + if self._client and self.has_more and idx >= len(self.documents) - 10: + await self.load_more(self._client) diff --git a/tests/test_inspector.py b/tests/test_inspector.py index 6515f89d..92a6005e 100644 --- a/tests/test_inspector.py +++ b/tests/test_inspector.py @@ -1,8 +1,10 @@ -from unittest.mock import patch +from unittest.mock import AsyncMock, patch +import pytest from typer.testing import CliRunner from haiku.rag.cli import cli +from haiku.rag.store.models import Document runner = CliRunner() @@ -16,3 +18,115 @@ def test_inspect_command(): assert result.exit_code == 0 mock_inspector.assert_called_once() + + +@pytest.mark.asyncio +async def test_document_list_loads_initial_batch(): + """Test that DocumentList loads only the initial batch on startup.""" + from textual.app import App + + from haiku.rag.inspector.widgets.document_list import DocumentList + + # Create mock documents + mock_docs = [ + Document(id=f"doc-{i}", content=f"Content {i}", title=f"Doc {i}") + for i in range(50) + ] + + class TestApp(App): + def compose(self): + yield DocumentList(id="doc-list") + + app = TestApp() + async with app.run_test(): + doc_list = app.query_one(DocumentList) + + # Create mock client + mock_client = AsyncMock() + mock_client.list_documents = AsyncMock(return_value=mock_docs) + + await doc_list.load_documents(mock_client) + + # Should have called list_documents with a limit (not None) + mock_client.list_documents.assert_called_once() + call_kwargs = mock_client.list_documents.call_args + # The limit should be set (not None) for initial load + assert call_kwargs.kwargs.get("limit") is not None + + +@pytest.mark.asyncio +async def test_document_list_load_more(): + """Test that DocumentList can load more documents.""" + from textual.app import App + + from haiku.rag.inspector.widgets.document_list import DocumentList + + # Create mock documents - two batches + batch1 = [ + Document(id=f"doc-{i}", content=f"Content {i}", title=f"Doc {i}") + for i in range(50) + ] + batch2 = [ + Document(id=f"doc-{i}", content=f"Content {i}", title=f"Doc {i}") + for i in range(50, 100) + ] + + class TestApp(App): + def compose(self): + yield DocumentList(id="doc-list") + + app = TestApp() + async with app.run_test(): + doc_list = app.query_one(DocumentList) + + mock_client = AsyncMock() + mock_client.list_documents = AsyncMock(side_effect=[batch1, batch2]) + + # Load initial batch + await doc_list.load_documents(mock_client) + assert len(doc_list.documents) == 50 + + # Load more + await doc_list.load_more(mock_client) + assert len(doc_list.documents) == 100 + + # Verify offset was used in second call + second_call = mock_client.list_documents.call_args_list[1] + assert second_call.kwargs.get("offset") == 50 + + +@pytest.mark.asyncio +async def test_document_list_tracks_has_more(): + """Test that DocumentList tracks whether more documents are available.""" + from textual.app import App + + from haiku.rag.inspector.widgets.document_list import DocumentList + + # First batch returns full page, second returns partial + batch1 = [ + Document(id=f"doc-{i}", content=f"Content {i}", title=f"Doc {i}") + for i in range(50) + ] + batch2 = [ + Document(id=f"doc-{i}", content=f"Content {i}", title=f"Doc {i}") + for i in range(50, 60) + ] + + class TestApp(App): + def compose(self): + yield DocumentList(id="doc-list") + + app = TestApp() + async with app.run_test(): + doc_list = app.query_one(DocumentList) + + mock_client = AsyncMock() + mock_client.list_documents = AsyncMock(side_effect=[batch1, batch2]) + + await doc_list.load_documents(mock_client) + # After loading full batch, has_more should be True + assert doc_list.has_more is True + + await doc_list.load_more(mock_client) + # After loading partial batch (<50), has_more should be False + assert doc_list.has_more is False