Skip to content

feat(search): add "Explore from GitHub" functionality#428

Merged
rainxchzed merged 2 commits into
mainfrom
search-backend-integ
Apr 17, 2026
Merged

feat(search): add "Explore from GitHub" functionality#428
rainxchzed merged 2 commits into
mainfrom
search-backend-integ

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented Apr 17, 2026

  • 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.

Summary by CodeRabbit

  • New Features
    • Added GitHub repository exploration with pagination support, enabling users to fetch and browse additional results directly from GitHub.
    • Implemented "Fetch more from GitHub" button with loading and exhausted states.
    • Added multi-language support with new UI strings across 12 languages for exploration, loading, and error states.

- 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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 17, 2026

Walkthrough

A new GitHub repository exploration feature with pagination is introduced across data, domain, and presentation layers. The changes include a new backend API endpoint (searchExplore) with corresponding DTOs, domain models (ExploreResult), repository methods, UI state and actions, pagination logic in the view model, and multilingual string resources supporting 15+ languages.

Changes

Cohort / File(s) Summary
Backend Data Transfer Objects
core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendExploreResponse.kt
New serializable DTO modeling paginated backend response with items list, page number, and hasMore flag.
Backend API Client
core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt
Added searchExplore() suspend function that calls search/explore endpoint with query, optional platform, and page parameters; includes 20-second timeout handling.
Domain Layer
feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/ExploreResult.kt, feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt
New ExploreResult data class and exploreFromGithub() method in SearchRepository interface for explore contract definition.
Repository Implementation
feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt
Implemented exploreFromGithub() with platform slug mapping (DiscoveryPlatform → backend platform values) and result transformation via toSummary().
UI Actions & State
feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt, feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt
Added ExploreFromGithub action variant; extended SearchState with visibleRepos field and nested ExploreStatus enum (IDLE/LOADING/EXHAUSTED).
View Model
feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt
Implemented explore pagination logic: performExplore() method with query validation, duplicate prevention, and result deduplication; appendExploreResults() filters against seen repositories and appends new items.
UI Composables
feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt
Added "Fetch more from GitHub" button UI element with state-driven rendering (idle/loading/exhausted); refactored to use state.visibleRepos directly from computed state.
Localization
core/presentation/src/commonMain/composeResources/values*/strings*.xml (16 files)
Added four consistent string keys across all language variants (ar, bn, es, fr, hi, it, ja, ko, pl, ru, tr, zh-rCN, and default): fetch_more_from_github, fetching_from_github, no_more_github_results, explore_error.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

  • PR #425: Introduces BackendApiClient and DTO serialization infrastructure that the new BackendExploreResponse and searchExplore() method build upon.
  • PR #340: Adds DiscoveryPlatform enum and typed platform APIs to SearchRepository and SearchRepositoryImpl, which the explore feature directly depends on for platform mapping.
  • PR #177: Modifies SearchRepositoryImpl and SearchViewModel for repository discovery; the main PR adds explore pagination to those same classes, creating shared implementation areas.

Poem

🐰 Hopping through the code, a wondrous sight!
Explore from GitHub, pagination bright!
Sixteen tongues now sing the UI's call,
Fetch more repos—we've got them all!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(search): add "Explore from GitHub" functionality' clearly and concisely summarizes the main feature being added across the changeset, following conventional commit format.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch search-backend-integ

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Explore button is unreachable on the "no results" path — the most valuable case for it.

ExploreFromGithubButton is nested inside if (state.visibleRepos.isNotEmpty()) (Line 544). When a search returns zero results, the error branch at Lines 522–542 renders instead (because performSearch sets errorMessage = no_repositories_found when allRepos.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 the DiscoveryPlatform → slug mapping.

The when (platform) block on Lines 436–442 is identical to the one in tryBackendSearch (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 returning Result<ExploreResult> for consistent error handling.

The existing searchRepositories(...) returns a Flow that lets callers react to errors, but exploreFromGithub throws on backend failure (via getOrThrow() in the impl). Downstream SearchViewModel will need to wrap calls in try/catch. Wrapping in Result<ExploreResult> (as DeveloperProfileRepository does) 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: import GithubRepoSummary instead of using a fully qualified type.

Using the fully qualified zed.rainxch.core.domain.model.GithubRepoSummary in the parameter list is noisier than adding it to the imports block (where DiscoveryRepositoryUi, 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 returns repos.isEmpty() on page 1, status goes straight to EXHAUSTED.

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 keeping visibleRepos derived rather than stored in state.

visibleRepos is effectively a projection of repositories filtered by seenRepoIds/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 from repositories when callers construct/copy SearchState without going through the mapping (previews, tests, future code paths). Since .copy is the idiomatic way to update state elsewhere in the VM, any .copy(repositories = …) without also updating visibleRepos yields 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

📥 Commits

Reviewing files that changed from the base of the PR and between 80feaa2 and 008d94f.

📒 Files selected for processing (22)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/BackendExploreResponse.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt
  • core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml
  • core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml
  • core/presentation/src/commonMain/composeResources/values-es/strings-es.xml
  • core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml
  • core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml
  • core/presentation/src/commonMain/composeResources/values-it/strings-it.xml
  • core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml
  • core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml
  • core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml
  • core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml
  • core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml
  • core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml
  • core/presentation/src/commonMain/composeResources/values/strings.xml
  • feature/search/data/src/commonMain/kotlin/zed/rainxch/search/data/repository/SearchRepositoryImpl.kt
  • feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/model/ExploreResult.kt
  • feature/search/domain/src/commonMain/kotlin/zed/rainxch/domain/repository/SearchRepository.kt
  • feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchAction.kt
  • feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchRoot.kt
  • feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchState.kt
  • feature/search/presentation/src/commonMain/kotlin/zed/rainxch/search/presentation/SearchViewModel.kt

Comment on lines +613 to 620
if (!state.isLoading && !state.isLoadingMore && state.query.isNotBlank()) {
item {
ExploreFromGithubButton(
status = state.exploreStatus,
onExplore = { onAction(SearchAction.ExploreFromGithub) },
)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Comment on lines 291 to 297
if (isInitial) {
currentSearchJob?.cancel()
currentPage = 1
explorePage = 1
lastExploreQuery = query
_state.update { it.copy(exploreStatus = SearchState.ExploreStatus.IDLE) }
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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:

  1. Triggers ExploreFromGithubexploreStatus = LOADING, request starts.
  2. Changes filter / hits search again → performSearch(isInitial = true) wipes repositories, resets exploreStatus = IDLE, resets explorePage.
  3. 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.

@rainxchzed rainxchzed merged commit 2104772 into main Apr 17, 2026
1 check passed
@rainxchzed rainxchzed deleted the search-backend-integ branch April 17, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant