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.client import HaikuRAG
from haiku.rag.store.models import Document from haiku.rag.store.models import Document
BATCH_SIZE = 50
class DocumentList(VerticalScroll): # pragma: no cover class DocumentList(VerticalScroll): # pragma: no cover
"""Widget for displaying and browsing documents.""" """Widget for displaying and browsing documents."""
@ -24,6 +26,9 @@ class DocumentList(VerticalScroll): # pragma: no cover
super().__init__(**kwargs) super().__init__(**kwargs)
self.documents: list[Document] = [] self.documents: list[Document] = []
self.list_view = ListView() self.list_view = ListView()
self.has_more: bool = True
self._client: HaikuRAG | None = None
self._loading: bool = False
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Compose the document list.""" """Compose the document list."""
@ -31,13 +36,29 @@ class DocumentList(VerticalScroll): # pragma: no cover
yield self.list_view yield self.list_view
async def load_documents(self, client: HaikuRAG) -> None: async def load_documents(self, client: HaikuRAG) -> None:
"""Load all documents from the database.""" """Load initial batch of documents from the database."""
self.documents = await client.list_documents(limit=None) 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() await self.list_view.clear()
for doc in self.documents: for doc in self.documents:
title = doc.title or doc.uri or doc.id title = doc.title or doc.uri or doc.id
await self.list_view.append(ListItem(Static(f"{title}"))) 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.Highlighted)
@on(ListView.Selected) @on(ListView.Selected)
async def handle_document_selection( async def handle_document_selection(
@ -49,3 +70,6 @@ class DocumentList(VerticalScroll): # pragma: no cover
idx = event.list_view.index idx = event.list_view.index
if idx is not None and 0 <= idx < len(self.documents): if idx is not None and 0 <= idx < len(self.documents):
self.post_message(self.DocumentSelected(self.documents[idx])) 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 typer.testing import CliRunner
from haiku.rag.cli import cli from haiku.rag.cli import cli
from haiku.rag.store.models import Document
runner = CliRunner() runner = CliRunner()
@ -16,3 +18,115 @@ def test_inspect_command():
assert result.exit_code == 0 assert result.exit_code == 0
mock_inspector.assert_called_once() 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