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.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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue