feat(search): add "Explore from GitHub" functionality#428
Conversation
- Introduce `BackendExploreResponse` DTO and `ExploreResult` domain model to handle paginated results from the backend's explore endpoint. - Extend `SearchRepository` and its implementation to support fetching additional repositories directly from GitHub via the backend. - Update `SearchViewModel` to manage explore state, including pagination, loading status, and deduplication of results against existing search items. - Enhance the search UI with an `ExploreFromGithubButton` and integrated loading/exhausted states. - Add localized strings for the new explore features in English, Arabic, Bengali, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Russian, Turkish, and Simplified Chinese. - Refactor `SearchState` to include `visibleRepos` as a managed property and introduce an `ExploreStatus` enum.
WalkthroughA new GitHub repository exploration feature with pagination is introduced across data, domain, and presentation layers. The changes include a new backend API endpoint ( Changes
Sequence DiagramsequenceDiagram
actor User
participant UI as SearchRoot<br/>(UI)
participant VM as SearchViewModel<br/>(Presentation)
participant Repo as SearchRepository<br/>(Domain/Data)
participant API as BackendApiClient<br/>(Data)
participant Backend as GitHub API<br/>(Backend)
User->>UI: Click "Fetch more from GitHub"
UI->>VM: Dispatch ExploreFromGithub Action
VM->>VM: performExplore()<br/>(validate query, check status)
VM->>VM: Update exploreStatus = LOADING
VM->>Repo: searchRepository.exploreFromGithub()<br/>(query, platform, page)
Repo->>Repo: Map DiscoveryPlatform<br/>to platform slug
Repo->>API: backendApiClient.searchExplore()<br/>(query, platform, page)
API->>Backend: GET /search/explore<br/>(with 20s timeout)
Backend-->>API: BackendExploreResponse<br/>(items, page, hasMore)
API-->>Repo: Result.success(response)
Repo->>Repo: Transform repos<br/>via toSummary()
Repo-->>VM: ExploreResult<br/>(repos, page, hasMore)
VM->>VM: appendExploreResults()<br/>(deduplicate, filter seenIds)
VM->>VM: Update exploreStatus<br/>= IDLE or EXHAUSTED
VM->>UI: State updated<br/>(repositories appended)
UI->>UI: Re-render grid<br/>with new repos
UI-->>User: Display fetched repos
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly Related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt (1)
522-624:⚠️ Potential issue | 🟠 MajorExplore button is unreachable on the "no results" path — the most valuable case for it.
ExploreFromGithubButtonis nested insideif (state.visibleRepos.isNotEmpty())(Line 544). When a search returns zero results, the error branch at Lines 522–542 renders instead (becauseperformSearchsetserrorMessage = no_repositories_foundwhenallRepos.isEmpty() && !hasMore), so the grid never renders and the user can't click "Fetch more from GitHub" — which is arguably the key scenario for this feature (GitHub-wide fallback when backend results are empty).Consider also rendering the explore button in the empty/error state (alongside or below the retry button), or at least when
state.errorMessage != null && state.query.isNotBlank().🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt` around lines 522 - 624, The ExploreFromGithubButton is currently only rendered when state.visibleRepos.isNotEmpty(), making it unreachable on the "no results" path; update SearchRoot.kt to also render ExploreFromGithubButton when the error/empty branch is shown (i.e., when state.errorMessage != null and state.query.isNotBlank()), placing it alongside the GithubStoreButton (Retry) so tapping triggers onAction(SearchAction.ExploreFromGithub); ensure the existing in-grid rendering of ExploreFromGithubButton (the item that checks !state.isLoading && !state.isLoadingMore && state.query.isNotBlank()) remains unchanged and reuse the same props (status = state.exploreStatus, onExplore = { onAction(SearchAction.ExploreFromGithub) }) so behavior is consistent.
🧹 Nitpick comments (5)
feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt (1)
431-455: Deduplicate theDiscoveryPlatform → slugmapping.The
when (platform)block on Lines 436–442 is identical to the one intryBackendSearch(Lines 116–122). Extract a private helper (e.g.,private fun DiscoveryPlatform.toBackendSlug(): String?) and reuse in both sites to avoid drift.♻️ Proposed refactor
+ private fun DiscoveryPlatform.toBackendSlug(): String? = when (this) { + DiscoveryPlatform.Android -> "android" + DiscoveryPlatform.Windows -> "windows" + DiscoveryPlatform.Macos -> "macos" + DiscoveryPlatform.Linux -> "linux" + DiscoveryPlatform.All -> null + } + override suspend fun exploreFromGithub( query: String, platform: DiscoveryPlatform, page: Int, ): ExploreResult { - val platformSlug = when (platform) { - DiscoveryPlatform.Android -> "android" - DiscoveryPlatform.Windows -> "windows" - DiscoveryPlatform.Macos -> "macos" - DiscoveryPlatform.Linux -> "linux" - DiscoveryPlatform.All -> null - } - val response = backendApiClient.searchExplore( query = query, - platform = platformSlug, + platform = platform.toBackendSlug(), page = page, ).getOrThrow()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt` around lines 431 - 455, The platform-to-slug mapping in exploreFromGithub duplicates the same when-block used in tryBackendSearch; extract a private helper extension like a private fun DiscoveryPlatform.toBackendSlug(): String? that returns "android", "windows", "macos", "linux" or null for All, then replace the when(...) usages in both exploreFromGithub and tryBackendSearch with calls to platform.toBackendSlug() so both methods reuse the same implementation and avoid drift.feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt (1)
21-25: Consider returningResult<ExploreResult>for consistent error handling.The existing
searchRepositories(...)returns aFlowthat lets callers react to errors, butexploreFromGithubthrows on backend failure (viagetOrThrow()in the impl). DownstreamSearchViewModelwill need to wrap calls in try/catch. Wrapping inResult<ExploreResult>(asDeveloperProfileRepositorydoes) would be more idiomatic here and avoid forcing exception-based control flow.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt` around lines 21 - 25, Change the exploreFromGithub API to return a Result to avoid throwing: update the signature of suspend fun exploreFromGithub(query: String, platform: DiscoveryPlatform, page: Int): Result<ExploreResult> and adjust its implementation (the function that currently calls getOrThrow()) to wrap the backend call in runCatching { ... } or return Result.success/Result.failure instead of throwing; remove usages of getOrThrow() in that implementation so callers (e.g., SearchViewModel) can handle success/failure via Result consistently like DeveloperProfileRepository.feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt (2)
713-715: Minor: importGithubRepoSummaryinstead of using a fully qualified type.Using the fully qualified
zed.rainxch.core.domain.model.GithubRepoSummaryin the parameter list is noisier than adding it to the imports block (whereDiscoveryRepositoryUi,SearchRepository, etc. already live).♻️ Proposed change
+import zed.rainxch.core.domain.model.GithubRepoSummary- private suspend fun appendExploreResults( - newRepos: List<zed.rainxch.core.domain.model.GithubRepoSummary>, - ) { + private suspend fun appendExploreResults(newRepos: List<GithubRepoSummary>) {🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt` around lines 713 - 715, Replace the fully-qualified type in the appendExploreResults parameter with an import: add an import for GithubRepoSummary (zed.rainxch.core.domain.model.GithubRepoSummary) at the top of the file and change the parameter signature in appendExploreResults to use GithubRepoSummary instead of zed.rainxch.core.domain.model.GithubRepoSummary; ensure other references in this file follow the same imported symbol if needed.
693-702: Edge case: when backend returnsrepos.isEmpty()on page 1, status goes straight toEXHAUSTED.If the very first explore call for a query returns no repos (e.g., backend has nothing new to contribute beyond current results), the UI jumps from IDLE → EXHAUSTED with no visible feedback that "explore ran and found nothing new." That's arguably correct — but if you'd prefer a distinct "no additional results" toast or a different message, surface an event here. Purely a UX call, not a defect.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt` around lines 693 - 702, When exploreResult.repos.isEmpty() and explorePage == 1, we currently jump straight to SearchState.ExploreStatus.EXHAUSTED; instead, detect that edge and surface a distinct UI event so the UI can show a "no additional results" message. Update the block that checks exploreResult.repos/isEmpty() in SearchViewModel (referencing exploreResult, repos, explorePage, appendExploreResults, _state.update, SearchState.ExploreStatus.EXHAUSTED) to: if page == 1 emit a one-time event on the existing event channel (or set a new ExploreStatus.NO_RESULTS flag) to signal "no new explore results", otherwise proceed with EXHAUSTED as now. Ensure explorePage increment and appendExploreResults behavior remains unchanged for non-empty responses.feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt (1)
15-15: Consider keepingvisibleReposderived rather than stored in state.
visibleReposis effectively a projection ofrepositoriesfiltered byseenRepoIds/isHideSeenEnabled, and the VM computes it via.map { it.copy(visibleRepos = computeVisibleRepos(it)) }. Storing it on the data class opens up the possibility of it drifting fromrepositorieswhen callers construct/copySearchStatewithout going through the mapping (previews, tests, future code paths). Since.copyis the idiomatic way to update state elsewhere in the VM, any.copy(repositories = …)without also updatingvisibleReposyields inconsistent state.Low priority — not an active bug given current callers. If you want to keep it stored for Compose stability/skipping, it's fine as-is.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt` at line 15, SearchState currently stores visibleRepos which duplicates the projection of repositories filtered by seenRepoIds/isHideSeenEnabled; remove the stored visibleRepos property from the SearchState data class and replace it with a derived accessor (e.g., a computed property or small helper function used where needed) that computes visibleRepos from repositories, seenRepoIds and isHideSeenEnabled (reuse the existing computeVisibleRepos logic used in the VM) so copies via SearchState.copy(repositories=...) cannot drift from the visible projection.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt`:
- Around line 613-620: The ExploreFromGithubButton (and the surrounding loading
indicator item) is rendered inside a StaggeredGrid and needs to span the full
row; modify the item declarations that wrap ExploreFromGithubButton and the
CircularProgressIndicator in SearchRoot.kt so they supply a span using
StaggeredGridItemSpan.FullLine (e.g. item(span = {
StaggeredGridItemSpan.FullLine })) so both components render full-width across
adaptive columns; locate the item { ... ExploreFromGithubButton(...) } and the
item { ... CircularProgressIndicator(...) } and add the span argument to each
item.
In
`@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt`:
- Around line 291-297: performExplore is launched without a Job reference so
stale explore responses can mutate state after a new search; add a nullable
exploreJob: Job? field, assign exploreJob = viewModelScope.launch { ... } inside
performExplore, and cancel exploreJob?.cancel() in performSearch(isInitial =
true) and in any filter-change branches before resetting explore state;
additionally, in appendExploreResults (or right before updating _state) guard by
comparing the captured query/lastExploreQuery against _state.value.query.trim()
(or require lastExploreQuery == _state.value.query.trim()) and bail out if they
differ to prevent merging stale explore results.
---
Outside diff comments:
In
`@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt`:
- Around line 522-624: The ExploreFromGithubButton is currently only rendered
when state.visibleRepos.isNotEmpty(), making it unreachable on the "no results"
path; update SearchRoot.kt to also render ExploreFromGithubButton when the
error/empty branch is shown (i.e., when state.errorMessage != null and
state.query.isNotBlank()), placing it alongside the GithubStoreButton (Retry) so
tapping triggers onAction(SearchAction.ExploreFromGithub); ensure the existing
in-grid rendering of ExploreFromGithubButton (the item that checks
!state.isLoading && !state.isLoadingMore && state.query.isNotBlank()) remains
unchanged and reuse the same props (status = state.exploreStatus, onExplore = {
onAction(SearchAction.ExploreFromGithub) }) so behavior is consistent.
---
Nitpick comments:
In
`@feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt`:
- Around line 431-455: The platform-to-slug mapping in exploreFromGithub
duplicates the same when-block used in tryBackendSearch; extract a private
helper extension like a private fun DiscoveryPlatform.toBackendSlug(): String?
that returns "android", "windows", "macos", "linux" or null for All, then
replace the when(...) usages in both exploreFromGithub and tryBackendSearch with
calls to platform.toBackendSlug() so both methods reuse the same implementation
and avoid drift.
In
`@feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt`:
- Around line 21-25: Change the exploreFromGithub API to return a Result to
avoid throwing: update the signature of suspend fun exploreFromGithub(query:
String, platform: DiscoveryPlatform, page: Int): Result<ExploreResult> and
adjust its implementation (the function that currently calls getOrThrow()) to
wrap the backend call in runCatching { ... } or return
Result.success/Result.failure instead of throwing; remove usages of getOrThrow()
in that implementation so callers (e.g., SearchViewModel) can handle
success/failure via Result consistently like DeveloperProfileRepository.
In
`@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt`:
- Line 15: SearchState currently stores visibleRepos which duplicates the
projection of repositories filtered by seenRepoIds/isHideSeenEnabled; remove the
stored visibleRepos property from the SearchState data class and replace it with
a derived accessor (e.g., a computed property or small helper function used
where needed) that computes visibleRepos from repositories, seenRepoIds and
isHideSeenEnabled (reuse the existing computeVisibleRepos logic used in the VM)
so copies via SearchState.copy(repositories=...) cannot drift from the visible
projection.
In
`@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt`:
- Around line 713-715: Replace the fully-qualified type in the
appendExploreResults parameter with an import: add an import for
GithubRepoSummary (zed.rainxch.core.domain.model.GithubRepoSummary) at the top
of the file and change the parameter signature in appendExploreResults to use
GithubRepoSummary instead of zed.rainxch.core.domain.model.GithubRepoSummary;
ensure other references in this file follow the same imported symbol if needed.
- Around line 693-702: When exploreResult.repos.isEmpty() and explorePage == 1,
we currently jump straight to SearchState.ExploreStatus.EXHAUSTED; instead,
detect that edge and surface a distinct UI event so the UI can show a "no
additional results" message. Update the block that checks
exploreResult.repos/isEmpty() in SearchViewModel (referencing exploreResult,
repos, explorePage, appendExploreResults, _state.update,
SearchState.ExploreStatus.EXHAUSTED) to: if page == 1 emit a one-time event on
the existing event channel (or set a new ExploreStatus.NO_RESULTS flag) to
signal "no new explore results", otherwise proceed with EXHAUSTED as now. Ensure
explorePage increment and appendExploreResults behavior remains unchanged for
non-empty responses.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 3077e1c6-fe4b-4d86-8b93-7e4d0e726123
📒 Files selected for processing (22)
core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendExploreResponse.ktcore/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.ktcore/presentation/src/commonMain/composeResources/values-ar/strings-ar.xmlcore/presentation/src/commonMain/composeResources/values-bn/strings-bn.xmlcore/presentation/src/commonMain/composeResources/values-es/strings-es.xmlcore/presentation/src/commonMain/composeResources/values-fr/strings-fr.xmlcore/presentation/src/commonMain/composeResources/values-hi/strings-hi.xmlcore/presentation/src/commonMain/composeResources/values-it/strings-it.xmlcore/presentation/src/commonMain/composeResources/values-ja/strings-ja.xmlcore/presentation/src/commonMain/composeResources/values-ko/strings-ko.xmlcore/presentation/src/commonMain/composeResources/values-pl/strings-pl.xmlcore/presentation/src/commonMain/composeResources/values-ru/strings-ru.xmlcore/presentation/src/commonMain/composeResources/values-tr/strings-tr.xmlcore/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xmlcore/presentation/src/commonMain/composeResources/values/strings.xmlfeature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.ktfeature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/ExploreResult.ktfeature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.ktfeature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.ktfeature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.ktfeature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.ktfeature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt
| if (!state.isLoading && !state.isLoadingMore && state.query.isNotBlank()) { | ||
| item { | ||
| ExploreFromGithubButton( | ||
| status = state.exploreStatus, | ||
| onExplore = { onAction(SearchAction.ExploreFromGithub) }, | ||
| ) | ||
| } | ||
| } |
There was a problem hiding this comment.
Explore button needs StaggeredGridItemSpan.FullLine to render full-width.
Since the grid uses StaggeredGridCells.Adaptive(350.dp), on wide viewports (tablets/desktop) it produces multiple columns. An item without an explicit span defaults to a single lane, so the button will render inside one column instead of spanning the full row — visually offset and easy to miss. The same applies to the item {} wrapping CircularProgressIndicator at lines 596–610.
🩹 Proposed fix
+import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan- item {
+ item(span = StaggeredGridItemSpan.FullLine) {
if (state.isLoadingMore) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
)
}
}
}
// "Fetch more from GitHub" explore button
if (!state.isLoading && !state.isLoadingMore && state.query.isNotBlank()) {
- item {
+ item(span = StaggeredGridItemSpan.FullLine) {
ExploreFromGithubButton(
status = state.exploreStatus,
onExplore = { onAction(SearchAction.ExploreFromGithub) },
)
}
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt`
around lines 613 - 620, The ExploreFromGithubButton (and the surrounding loading
indicator item) is rendered inside a StaggeredGrid and needs to span the full
row; modify the item declarations that wrap ExploreFromGithubButton and the
CircularProgressIndicator in SearchRoot.kt so they supply a span using
StaggeredGridItemSpan.FullLine (e.g. item(span = {
StaggeredGridItemSpan.FullLine })) so both components render full-width across
adaptive columns; locate the item { ... ExploreFromGithubButton(...) } and the
item { ... CircularProgressIndicator(...) } and add the span argument to each
item.
| if (isInitial) { | ||
| currentSearchJob?.cancel() | ||
| currentPage = 1 | ||
| explorePage = 1 | ||
| lastExploreQuery = query | ||
| _state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) } | ||
| } |
There was a problem hiding this comment.
Potential stale-state race: in-flight explore isn't cancelled on new search/filter change.
performExplore() runs as a detached viewModelScope.launch with no stored Job reference. If the user:
- Triggers
ExploreFromGithub→exploreStatus = LOADING, request starts. - Changes filter / hits search again →
performSearch(isInitial = true)wipesrepositories, resetsexploreStatus = IDLE, resetsexplorePage. - Step 1's explore response returns.
The response will call appendExploreResults and merge stale explore repos into the fresh search's repositories, then overwrite exploreStatus to IDLE/EXHAUSTED. The user sees results from a query they abandoned.
Also, there's no guard inside appendExploreResults checking that _state.value.query == lastExploreQuery at the time of the update.
Consider keeping an exploreJob: Job? and cancelling it in performSearch(isInitial = true) and the filter-change branches, and/or comparing the captured query against _state.value.query.trim() before mutating state.
🩹 Proposed fix sketch
private var explorePage = 1
private var lastExploreQuery = ""
+ private var exploreJob: Job? = null if (isInitial) {
currentSearchJob?.cancel()
+ exploreJob?.cancel()
currentPage = 1
explorePage = 1
lastExploreQuery = query
_state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) }
}- viewModelScope.launch {
+ exploreJob = viewModelScope.launch {
_state.update { it.copy(exploreStatus = SearchState.ExploreStatus.LOADING) }
try {
val exploreResult = searchRepository.exploreFromGithub(
query = query,
platform = _state.value.selectedSearchPlatform.toDomain(),
page = explorePage,
)
+ // Bail out if the query changed while we were loading.
+ if (_state.value.query.trim() != query) return@launch
…
}
}Also applies to: 673-711
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt`
around lines 291 - 297, performExplore is launched without a Job reference so
stale explore responses can mutate state after a new search; add a nullable
exploreJob: Job? field, assign exploreJob = viewModelScope.launch { ... } inside
performExplore, and cancel exploreJob?.cancel() in performSearch(isInitial =
true) and in any filter-change branches before resetting explore state;
additionally, in appendExploreResults (or right before updating _state) guard by
comparing the captured query/lastExploreQuery against _state.value.query.trim()
(or require lastExploreQuery == _state.value.query.trim()) and bail out if they
differ to prevent merging stale explore results.
BackendExploreResponseDTO andExploreResultdomain model to handle paginated results from the backend's explore endpoint.SearchRepositoryand its implementation to support fetching additional repositories directly from GitHub via the backend.SearchViewModelto manage explore state, including pagination, loading status, and deduplication of results against existing search items.ExploreFromGithubButtonand integrated loading/exhausted states.SearchStateto includevisibleReposas a managed property and introduce anExploreStatusenum.Summary by CodeRabbit