Infinite scroll lazy load document list in inspector
This commit is contained in:
parent
9e16e8dc98
commit
156bba3359
2 changed files with 141 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue