diff --git a/frontend/commands.js b/frontend/commands.js
index 569a071..e2ca1ee 100644
--- a/frontend/commands.js
+++ b/frontend/commands.js
@@ -15,6 +15,15 @@
return current;
};
+ filterCommands.searchUrl = function searchUrl(query, page, scope, continuation) {
+ const streamPages = continuation ?
+ (continuation.issues ? '&issues_page=' + encodeURIComponent(continuation.issues) : '') +
+ (continuation.pulls ? '&pulls_page=' + encodeURIComponent(continuation.pulls) : '') : '';
+ return 'api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
+ '&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
+ (scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : '') + streamPages;
+ };
+
filterCommands.createGlobalSearchController = function createGlobalSearchController(options) {
const search = options.search;
const onState = options.onState;
@@ -31,12 +40,14 @@
return state;
}
- async function requestPage(query, page, current, append) {
+ async function requestPage(query, page, current, append, continuation) {
const requestController = new AbortController();
activeController = requestController;
try {
const requestScope = { ...scope };
- const result = await search(query, requestController.signal, page, requestScope);
+ const result = await search(
+ query, requestController.signal, page, requestScope, continuation
+ );
const partial = result.partial === true;
const incoming = result.items || result;
const combined = append ? state.items.concat(incoming) : incoming;
@@ -47,6 +58,8 @@
status: 'ready', query, items, partial,
more: !!result.has_more,
next: result.next_page,
+ continuation:result.continuation,
+ failedStreams:result.failed_streams || [],
scope:requestScope,
});
} catch (error) {
@@ -93,7 +106,7 @@
if (state.status !== 'ready' || !state.more || activeController) return Promise.resolve(state);
const current = generation;
const page = state.next;
- return requestPage(state.query, page, current, true);
+ return requestPage(state.query, page, current, true, state.continuation);
},
};
};
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index a6f00c7..a0c4a38 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -4293,10 +4293,8 @@
let commandSearchState = { status:'idle', query:'', items:[] };
let commandItems = [];
let commandSelection = -1;
- async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}) {
- const response = await fetch('api/v1/search?q=' + encodeURIComponent(query) + '&limit=10&page=' + page +
- '&kind=' + encodeURIComponent(scope.kind) + '&state=' + encodeURIComponent(scope.state) +
- (scope.repository ? '&repository=' + encodeURIComponent(scope.repository) : ''), {
+ async function searchGlobalWork(query, signal, page = 1, scope = {kind:'all', state:'all'}, continuation) {
+ const response = await fetch(filterCommands.searchUrl(query, page, scope, continuation), {
headers: { Accept:'application/json' },
signal,
});
@@ -4531,6 +4529,7 @@
else if (String(filter || '').trim().length >= 2 && !remote.length) html += '
No matching issues or pull requests.
';
el.innerHTML = html;
loadMore.hidden = !commandSearchState.more;
+ loadMore.textContent = commandSearchState.partial ? 'Retry missing results' : 'More results';
el.querySelectorAll('.cmd-item').forEach((item) => {
item.addEventListener('click', () => runCommandItem(commandItems[Number(item.dataset.idx)]));
});
diff --git a/src/gitea_proxy.py b/src/gitea_proxy.py
index 1482496..b79d807 100644
--- a/src/gitea_proxy.py
+++ b/src/gitea_proxy.py
@@ -470,16 +470,31 @@ async def global_search(
kind: str = "all",
state: str = "all",
repository: str | None = None,
+ continuation: dict[str, int | None] | None = None,
) -> dict:
- """Search accessible issues and pulls concurrently with balanced pagination."""
+ """Search accessible work with lossless, independently retryable streams."""
item_types = ("issues", "pulls") if kind == "all" else (
"issues" if kind == "issue" else "pulls",
)
- stream_limit = (limit + 1) // 2 if kind == "all" else limit
+ stream_limits = {
+ item_type: (
+ (limit + 1) // 2 if item_type == "issues" else limit // 2
+ ) if kind == "all" else limit
+ for item_type in item_types
+ }
+ stream_pages = {
+ item_type: continuation.get(item_type) if continuation is not None else page
+ for item_type in item_types
+ }
+ active_types = tuple(
+ item_type for item_type in item_types
+ if stream_pages[item_type] is not None and stream_limits[item_type] > 0
+ )
+
async def load(item_type: str) -> Any:
params = {
"q": query, "type": item_type, "state": state,
- "limit": stream_limit, "page": page,
+ "limit": stream_limits[item_type], "page": stream_pages[item_type],
}
if repository:
owner, repo = repository.split("/", 1)
@@ -495,16 +510,20 @@ async def global_search(
raise ValueError("Gitea global search response was not a list")
return payload
- outcomes = await asyncio.gather(*(load(item_type) for item_type in item_types), return_exceptions=True)
+ outcomes = await asyncio.gather(
+ *(load(item_type) for item_type in active_types), return_exceptions=True
+ )
for outcome in outcomes:
if isinstance(outcome, asyncio.CancelledError):
raise outcome
- if all(isinstance(outcome, BaseException) for outcome in outcomes):
+ if outcomes and all(isinstance(outcome, BaseException) for outcome in outcomes):
raise outcomes[0]
streams: list[list[dict]] = []
seen: set[tuple[str, str, int]] = set()
- result_kinds = tuple("issue" if item_type == "issues" else "pull" for item_type in item_types)
+ result_kinds = tuple(
+ "issue" if item_type == "issues" else "pull" for item_type in active_types
+ )
for outcome, result_kind in zip(outcomes, result_kinds, strict=True):
normalized_stream = []
if isinstance(outcome, BaseException):
@@ -524,14 +543,26 @@ async def global_search(
for stream in streams:
if index < len(stream):
results.append(stream[index])
+
+ failed_streams = [
+ "issue" if item_type == "issues" else "pull"
+ for item_type, outcome in zip(active_types, outcomes, strict=True)
+ if isinstance(outcome, BaseException)
+ ]
+ next_continuation = {item_type: None for item_type in item_types}
+ for item_type, outcome in zip(active_types, outcomes, strict=True):
+ if isinstance(outcome, BaseException):
+ next_continuation[item_type] = stream_pages[item_type]
+ elif len(outcome) >= stream_limits[item_type]:
+ next_continuation[item_type] = int(stream_pages[item_type]) + 1
+ has_more = any(value is not None for value in next_continuation.values())
return {
"items": results[:limit],
- "partial": any(isinstance(outcome, BaseException) for outcome in outcomes),
- "has_more": any(
- not isinstance(outcome, BaseException) and len(outcome) >= stream_limit
- for outcome in outcomes
- ),
+ "partial": bool(failed_streams),
+ "has_more": has_more,
"next_page": page + 1,
+ "continuation": next_continuation,
+ "failed_streams": failed_streams,
}
diff --git a/src/main.py b/src/main.py
index 7d5f05c..4c218fa 100644
--- a/src/main.py
+++ b/src/main.py
@@ -2732,15 +2732,25 @@ async def global_search(
repository: str | None = Query(
default=None, pattern=r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$", max_length=161
),
+ issues_page: int | None = Query(default=None, ge=1, le=100),
+ pulls_page: int | None = Query(default=None, ge=1, le=100),
) -> JSONResponse:
query = q.strip()
if len(query) < 2:
raise HTTPException(status_code=422, detail="Search query must contain at least 2 characters")
try:
arguments = (query, limit, page, kind, state)
- operation = gitea_proxy.global_search(*arguments, repository) if repository else (
- gitea_proxy.global_search(*arguments)
- )
+ continuation = None
+ if issues_page is not None or pulls_page is not None:
+ continuation = {"issues": issues_page, "pulls": pulls_page}
+ if continuation is not None:
+ operation = gitea_proxy.global_search(
+ *arguments, repository, continuation
+ )
+ elif repository:
+ operation = gitea_proxy.global_search(*arguments, repository)
+ else:
+ operation = gitea_proxy.global_search(*arguments)
result = await asyncio.wait_for(
operation,
timeout=GLOBAL_SEARCH_TIMEOUT_SECONDS,
diff --git a/tests/test_api_paths.py b/tests/test_api_paths.py
index aaf3e4d..649839d 100644
--- a/tests/test_api_paths.py
+++ b/tests/test_api_paths.py
@@ -8,6 +8,8 @@ from tests.dashboard_bundle import dashboard_bundle_text
def test_api_requests_resolve_inside_dashboard_subpath():
html = dashboard_bundle_text()
api_paths = re.findall(r"fetch\(['\"]([^'\"]*api/v1/[^'\"]*)['\"]", html)
+ commands = (Path(__file__).parents[1] / "frontend" / "commands.js").read_text()
+ api_paths += re.findall(r"return ['\"]([^'\"]*api/v1/[^'\"]*)['\"]", commands)
assert api_paths
assert {
diff --git a/tests/test_command_palette.py b/tests/test_command_palette.py
index a5bf65e..0c820d6 100644
--- a/tests/test_command_palette.py
+++ b/tests/test_command_palette.py
@@ -56,6 +56,7 @@ def test_command_script_resolves_inside_dashboard_subpath():
def test_mobile_search_renders_touch_sized_type_and_status_scope_controls():
html = dashboard_bundle_text()
css = (FRONTEND / "dashboard.css").read_text()
+ commands = COMMANDS.read_text()
assert '