Infinite scroll lazy load document list in inspector

This commit is contained in:
Yiorgis Gozadinos 2025-12-09 17:48:27 +02:00
parent 9e16e8dc98
commit 156bba3359
No known key found for this signature in database
2 changed files with 141 additions and 3 deletions

View file

@ -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)

View file

@ -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