Skip to content

feat(details): manual refresh via /v1/repo/{owner}/{name}/refresh#510

Merged
rainxchzed merged 9 commits into
mainfrom
feat/details-manual-refresh
May 4, 2026
Merged

feat(details): manual refresh via /v1/repo/{owner}/{name}/refresh#510
rainxchzed merged 9 commits into
mainfrom
feat/details-manual-refresh

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 4, 2026

Summary

Wires the freshly-shipped backend POST /v1/repo/{owner}/{name}/refresh endpoint into the Details screen so users can force-fresh repo metadata + latest release on demand.

How it surfaces

  • Always-visible overflow menu on the Details TopBar (was previously hidden unless the repo was a manual external import). First item is Refresh, with the existing Unlink from this repo entry kept below for MANUAL installs.
  • Pull-to-refresh on Android only (gated via a new isPullToRefreshSupported() expect/actual). Desktop skips the gesture — no natural mouse mapping — and instead picks up:
  • Ctrl/Cmd+R keyboard shortcut on the Details list (works on both targets, but only desktop users will reach for it).
  • Cooldown UX: when the backend returns 429 cooldown or budget_exhausted, the menu item disables and shows a live Refresh (Ns) countdown driven by a LaunchedEffect ticker; a Snackbar surfaces the human-readable reason.
  • Terminal errors: distinct snackbar copy for archived (410), not_found (404), github_unreachable (502), and budget_exhausted (429-with-budget-body).

Architecture

  • BackendApiClient.refreshRepo(owner, name) — POSTs the new endpoint, attaches X-GitHub-Token if signed in, parses the 429 body to discriminate cooldown vs budget_exhausted, and returns typed exceptions.
  • New domain RefreshException(kind, retryAfterSeconds) + RefreshError enum so the presentation layer doesn't depend on core/data internals.
  • DetailsRepositoryImpl.refreshRepository calls the backend, busts the local repo / stats / latest-release / releases caches on success, and re-throws data-layer exceptions as RefreshException for the ViewModel.
  • DetailsViewModel.refresh() short-circuits while a client-side cooldown is active, otherwise replaces repository/stats/allReleases with the fresh response, then re-runs getAllReleases + getRepoStats to consume the busted caches. No fallback to GitHub direct (per backend doc §7).

Test plan

  • Android: pull-to-refresh on Details swaps repo data for fresh values, then a second pull within 30s triggers the cooldown snackbar and disables the menu item with a live countdown.
  • Android: archived repo (e.g. ranger/ranger if archived upstream) returns 410 → snackbar shows "This repository is archived on GitHub."
  • Desktop: Ctrl/Cmd+R while focused on the details list triggers refresh.
  • Desktop: pull-to-refresh wrapper does not appear (verify no top-of-list drag indicator).
  • Both: signed-in user's token is forwarded to the refresh endpoint (network log should show X-GitHub-Token).
  • Both: non-existent repo returns 404 → snackbar shows "no longer exists" copy.
  • What's-new sheet on next 1.8.1 install shows the new "Manual refresh on details" bullet in the device language.

Summary by CodeRabbit

  • New Features

    • Manual refresh on repository details: pull-to-refresh on Android, “Refresh” in the overflow menu on all platforms, and Ctrl/Cmd+R on desktop.
    • Overflow menu shows cooldown-aware label and disables refresh while cooling down or refreshing.
    • Clear UI feedback for cooldowns, service budget exhaustion, archived/missing repos, upstream errors, and generic failures.
  • Localization

    • Added localized strings and updated “What’s New” entries describing manual refresh.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 3981211e-64ba-4d9f-9f7a-9a17679d9220

📥 Commits

Reviewing files that changed from the base of the PR and between cca4290 and 5d33807.

📒 Files selected for processing (2)
  • core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
✅ Files skipped from review due to trivial changes (1)
  • core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml
🚧 Files skipped from review as they are similar to previous changes (1)
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Walkthrough

Adds a manual refresh flow for repository details: backend POST endpoint with refresh-specific 429 parsing and new exceptions, a domain RefreshException enum/type, repository refresh API and cache invalidation, ViewModel/UI wiring (pull-to-refresh, keyboard, overflow menu), platform support flags, localized strings, and updated release notes.

Changes

Manual Repository Refresh Feature

Layer / File(s) Summary
Domain Model
core/domain/.../RefreshException.kt
Adds RefreshError enum and RefreshException(kind, retryAfterSeconds?).
Backend API
core/data/.../BackendApiClient.kt
Adds refreshRepo(owner,name) POST with 15s timeout; implements parseRefresh429/parseRefreshErrorBody; adds DEFAULT_REFRESH_COOLDOWN_SECONDS and exceptions: RefreshCooldownException, RefreshBudgetExhaustedException, RepoNotFoundException, RepoArchivedException.
Data Repository
feature/details/domain/.../DetailsRepository.kt, feature/details/data/.../DetailsRepositoryImpl.kt
Adds DetailsRepository.refreshRepository(owner,name) and implementation that calls backend, maps throwables to RefreshException via Throwable.toRefreshException(), persists refreshed GithubRepoSummary, and invalidates related caches.
Presentation State & Actions
feature/details/presentation/.../DetailsState.kt, DetailsAction.kt, DetailsEvent.kt
Adds isRefreshing and refreshCooldownUntilEpochMs to state, DetailsAction.Refresh, and DetailsEvent.OnRefreshError(kind, retryAfterSeconds?).
Presentation UI & ViewModel
feature/details/presentation/.../DetailsRoot.kt, DetailsViewModel.kt
ViewModel adds refresh() with cooldown gating, concurrent releases/stats reload, selected-release resolution, and error handling that sets cooldown and emits OnRefreshError. UI adds conditional PullToRefreshHost, keyboard Ctrl/Meta+R handling, cooldown-aware overflow menu DetailsOverflowMenu, and snackbar mapping.
Platform Support
core/presentation/.../isPullToRefreshSupported.kt, ...android.kt, ...jvm.kt
Adds expect isPullToRefreshSupported(); Android actual = true, JVM actual = false.
Localization
core/presentation/.../composeResources/values*/strings*.xml
Adds details_refresh* string resources (action, cooldown, more options, multiple snackbar messages) across locales.
Release Notes
core/presentation/.../composeResources/files/whatsnew/*/16.json
Updates "NEW" section in multiple locales to document manual refresh (pull-to-refresh on Android, overflow-menu Refresh, Ctrl/Cmd+R on desktop).

Sequence Diagram

sequenceDiagram
    participant User
    participant UI as DetailsScreen
    participant VM as DetailsViewModel
    participant Repo as DetailsRepository
    participant API as BackendApiClient
    participant Backend as Backend Service

    User->>UI: Pull / Click refresh / Press Ctrl+R
    UI->>VM: onAction(Refresh)
    VM->>VM: check cooldown & isRefreshing
    alt cooldown active
        VM-->>UI: emit OnRefreshError(COOLDOWN, retryAfterSeconds)
        UI-->>User: show cooldown snackbar
    else allowed
        VM->>Repo: refreshRepository(owner,name)
        Repo->>API: refreshRepo(owner,name)
        API->>Backend: POST /repo/{owner}/{name}/refresh
        alt 2xx
            Backend-->>API: BackendRepoResponse
            API-->>Repo: success
            Repo-->>VM: GithubRepoSummary
            VM-->>UI: update state with refreshed data
        else 429
            Backend-->>API: 429 with body/header
            API->>Repo: throws RefreshCooldownException or RefreshBudgetExhaustedException
            Repo-->>VM: RefreshException(kind, retryAfterSeconds)
            VM-->>UI: emit OnRefreshError(kind, retryAfterSeconds)
        else 404/410
            Backend-->>API: 404/410
            API->>Repo: throws RepoNotFoundException/RepoArchivedException
            Repo-->>VM: RefreshException(NOT_FOUND/ARCHIVED)
            VM-->>UI: emit OnRefreshError(kind)
        else other
            API-->>Repo: BackendException
            Repo-->>VM: RefreshException(UPSTREAM/GENERIC)
            VM-->>UI: emit OnRefreshError(GENERIC)
        end
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

Poem

🐰
I nibble bytes and watch the feed,
A pull, a click — fresh data freed.
Cooldowns tick, the snackbar sings,
Backend hums and new info springs.
Hooray for refresh — hoppity, hop, repeat!

🚥 Pre-merge checks | ✅ 4 | ❌ 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 (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the main feature: adding manual refresh functionality via the backend endpoint /v1/repo/{owner}/{name}/refresh on the Details screen.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 feat/details-manual-refresh

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
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

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: 6

🧹 Nitpick comments (1)
core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.kt (1)

1-3: ⚖️ Poor tradeoff

Align the package with the repo convention.

zed.rainxch.core.presentation.utils adds a third segment that doesn't fit the repo's zed.rainxch.{module}.{layer} package pattern. Please move this expect helper into the presentation layer package and keep the platform actuals aligned as well. As per coding guidelines, package names must follow the pattern zed.rainxch.{module}.{layer}.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.kt`
around lines 1 - 3, The expect declaration is in the wrong package; change its
package from zed.rainxch.core.presentation.utils to
zed.rainxch.core.presentation and update all matching platform actual
implementations to the same package so the expect/actual linkage resolves;
specifically move the expect fun isPullToRefreshSupported() into package
zed.rainxch.core.presentation and ensure each actual isPullToRefreshSupported in
platform source sets uses that same package.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt`:
- Around line 447-461: In parseRefreshErrorBody, remove the early return that
checks response.contentLength() == 0L so we don't skip parsing when
Content-Length is absent (e.g., chunked responses); instead always call
response.bodyAsText(), keep the existing text.isBlank() guard and JSON parsing
logic, and preserve the try/catch/return behavior that yields error and
retry_after from the JsonObject (refer to the parseRefreshErrorBody function and
its use of response.contentLength(), response.bodyAsText(),
Json.parseToJsonElement, and the extraction of "error" and "retry_after").

In `@core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml`:
- Around line 831-837: The French strings containing escaped apostrophes (e.g.,
details_refresh_more_options, details_refresh_snackbar_cooldown,
details_refresh_snackbar_budget_exhausted, details_refresh_snackbar_not_found,
details_refresh_snackbar_upstream, details_refresh_snackbar_generic) currently
include backslashes before apostrophes (\' ) which render literally; update each
string value to remove the backslashes and use a plain apostrophe (') or a
proper XML entity (') so the UI shows correct French text (for example
change "d\'actualiser" to "d'actualiser").

In `@core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml`:
- Around line 866-873: The Turkish strings details_refresh_snackbar_archived and
details_refresh_snackbar_not_found contain escaped backslashes before
apostrophes (GitHub\'da) which render literally; edit those entries to remove
the backslashes so they read GitHub'da (plain apostrophe) instead of GitHub\'da,
leaving all other strings unchanged.

In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt`:
- Around line 535-541: The handlers that call onAction(DetailsAction.Refresh)
(notably the onPreviewKeyEvent Ctrl/Cmd+R branch and the pull-to-refresh
callback) must guard against dispatching while a refresh is already running:
check state.isRefreshing and only call onAction(DetailsAction.Refresh) when
false, returning/consuming the event accordingly; update the onPreviewKeyEvent
lambda and the pull-to-refresh refresh handler to first test state.isRefreshing
and skip/return true (or no-op) if a refresh is in progress.

In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt`:
- Around line 2653-2656: When computing the new selectedRelease from
freshReleases, first try to preserve the current selection by finding an item in
freshReleases that matches the existing _state.value.selectedRelease (e.g.,
compare a stable unique identity like id/version), and only if no match is found
fall back to the existing logic (first non-prerelease via
isEffectivelyPreRelease() or firstOrNull). Update the block that builds
selectedRelease to search freshReleases for a match against
_state.value.selectedRelease before using the first stable/first release
fallback.
- Around line 2633-2645: The async blocks that build releasesDeferred and
statsDeferred are using runCatching which swallows CancellationException; update
both blocks in refresh() to use explicit try/catch around
detailsRepository.getAllReleases(...) and detailsRepository.getRepoStats(...)
respectively, catching Throwable but rethrowing CancellationException
immediately (and only handling non-cancellation errors), so cancellations
propagate to the outer try/catch and preserve structured concurrency; keep the
same return/result wrapping used elsewhere in the codebase after converting
runCatching to the try/catch+rethrow pattern.

---

Nitpick comments:
In
`@core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.kt`:
- Around line 1-3: The expect declaration is in the wrong package; change its
package from zed.rainxch.core.presentation.utils to
zed.rainxch.core.presentation and update all matching platform actual
implementations to the same package so the expect/actual linkage resolves;
specifically move the expect fun isPullToRefreshSupported() into package
zed.rainxch.core.presentation and ensure each actual isPullToRefreshSupported in
platform source sets uses that same package.
🪄 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: eeba3ccb-af68-4343-92c7-4b627aa11173

📥 Commits

Reviewing files that changed from the base of the PR and between bb75e6b and 72bcd52.

📒 Files selected for processing (38)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/RefreshException.kt
  • core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.android.kt
  • core/presentation/src/commonMain/composeResources/files/whatsnew/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/16.json
  • 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
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.kt
  • core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.jvm.kt
  • feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt
  • feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Comment on lines +447 to +461
private suspend fun parseRefreshErrorBody(
response: io.ktor.client.statement.HttpResponse,
): Pair<String?, Long?> {
val contentLength = response.contentLength() ?: 0L
if (contentLength == 0L) return null to null
return try {
val text = response.bodyAsText()
if (text.isBlank()) return null to null
val obj = Json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject
?: return null to null
val error = (obj["error"] as? kotlinx.serialization.json.JsonPrimitive)?.contentOrNull
val retryAfter = (obj["retry_after"] as? kotlinx.serialization.json.JsonPrimitive)
?.contentOrNull
?.toLongOrNull()
error to retryAfter
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

🧩 Analysis chain

🌐 Web query:

Does Ktor HttpResponse.contentLength()return null when the response omits theContent-Length header, such as with chunked transfer encoding?

💡 Result:

Yes. Ktor’s HttpResponse.contentLength (and the underlying parsing it relies on) returns null when the response does not include a valid Content-Length header, which is the case for responses that omit Content-Length (e.g., when the transfer uses Transfer-Encoding: chunked).

Citations:


Remove the Content-Length gate from refresh-429 parsing.

The current implementation returns null to null for responses that omit the Content-Length header (e.g., with chunked transfer encoding), even when a valid JSON body is present. This silently skips the error and retry_after fields, collapsing budget_exhausted into the generic 429 path and breaking the distinct snackbar/cooldown UX.

The isBlank() check on the parsed body is sufficient; remove the contentLength() gate entirely.

Suggested fix
 private suspend fun parseRefreshErrorBody(
     response: io.ktor.client.statement.HttpResponse,
 ): Pair<String?, Long?> {
-    val contentLength = response.contentLength() ?: 0L
-    if (contentLength == 0L) return null to null
     return try {
         val text = response.bodyAsText()
         if (text.isBlank()) return null to null
         val obj = Json.parseToJsonElement(text) as? kotlinx.serialization.json.JsonObject
             ?: return null to null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt`
around lines 447 - 461, In parseRefreshErrorBody, remove the early return that
checks response.contentLength() == 0L so we don't skip parsing when
Content-Length is absent (e.g., chunked responses); instead always call
response.bodyAsText(), keep the existing text.isBlank() guard and JSON parsing
logic, and preserve the try/catch/return behavior that yields error and
retry_after from the JsonObject (refer to the parseRefreshErrorBody function and
its use of response.contentLength(), response.bodyAsText(),
Json.parseToJsonElement, and the extraction of "error" and "retry_after").

Comment on lines +831 to +837
<string name="details_refresh_more_options">Plus d\'options</string>
<string name="details_refresh_snackbar_cooldown">Vous venez d\'actualiser — réessayez dans %1$d s.</string>
<string name="details_refresh_snackbar_budget_exhausted">Service d\'actualisation occupé — réessayez dans %1$d s.</string>
<string name="details_refresh_snackbar_archived">Ce dépôt est archivé sur GitHub.</string>
<string name="details_refresh_snackbar_not_found">Ce dépôt n\'existe plus sur GitHub.</string>
<string name="details_refresh_snackbar_upstream">Échec de l\'actualisation. Réessayez bientôt.</string>
<string name="details_refresh_snackbar_generic">Échec de l\'actualisation. Réessayez.</string>
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 | ⚡ Quick win

Remove the escaped apostrophes from the French refresh copy.

\' is rendered literally in XML text, so these strings will show backslashes in the UI. Please replace them with normal apostrophes.

Fix
-    <string name="details_refresh_more_options">Plus d\'options</string>
-    <string name="details_refresh_snackbar_cooldown">Vous venez d\'actualiser — réessayez dans %1$d s.</string>
-    <string name="details_refresh_snackbar_budget_exhausted">Service d\'actualisation occupé — réessayez dans %1$d s.</string>
-    <string name="details_refresh_snackbar_not_found">Ce dépôt n\'existe plus sur GitHub.</string>
-    <string name="details_refresh_snackbar_upstream">Échec de l\'actualisation. Réessayez bientôt.</string>
+    <string name="details_refresh_more_options">Plus d’options</string>
+    <string name="details_refresh_snackbar_cooldown">Vous venez d’actualiser — réessayez dans %1$d s.</string>
+    <string name="details_refresh_snackbar_budget_exhausted">Service d’actualisation occupé — réessayez dans %1$d s.</string>
+    <string name="details_refresh_snackbar_not_found">Ce dépôt n’existe plus sur GitHub.</string>
+    <string name="details_refresh_snackbar_upstream">Échec de l’actualisation. Réessayez bientôt.</string>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml`
around lines 831 - 837, The French strings containing escaped apostrophes (e.g.,
details_refresh_more_options, details_refresh_snackbar_cooldown,
details_refresh_snackbar_budget_exhausted, details_refresh_snackbar_not_found,
details_refresh_snackbar_upstream, details_refresh_snackbar_generic) currently
include backslashes before apostrophes (\' ) which render literally; update each
string value to remove the backslashes and use a plain apostrophe (') or a
proper XML entity (&apos;) so the UI shows correct French text (for example
change "d\'actualiser" to "d'actualiser").

Comment on lines +866 to +873
<string name="details_refresh_cooldown">Yenile (%1$dsn)</string>
<string name="details_refresh_more_options">Diğer seçenekler</string>
<string name="details_refresh_snackbar_cooldown">Az önce yenilediniz — %1$d sn sonra tekrar deneyin.</string>
<string name="details_refresh_snackbar_budget_exhausted">Yenileme hizmeti meşgul — %1$d sn sonra tekrar deneyin.</string>
<string name="details_refresh_snackbar_archived">Bu depo GitHub\'da arşivlenmiş.</string>
<string name="details_refresh_snackbar_not_found">Bu depo artık GitHub\'da bulunmuyor.</string>
<string name="details_refresh_snackbar_upstream">Yenileme başarısız. Birazdan tekrar deneyin.</string>
<string name="details_refresh_snackbar_generic">Yenileme başarısız. Tekrar deneyin.</string>
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 | ⚡ Quick win

Remove the escaped apostrophes from the Turkish refresh copy.

The backslashes in GitHub\'da are literal in XML text, so they will show up to users. Please switch these to plain apostrophes.

Fix
-    <string name="details_refresh_snackbar_archived">Bu depo GitHub\'da arşivlenmiş.</string>
-    <string name="details_refresh_snackbar_not_found">Bu depo artık GitHub\'da bulunmuyor.</string>
+    <string name="details_refresh_snackbar_archived">Bu depo GitHub'da arşivlenmiş.</string>
+    <string name="details_refresh_snackbar_not_found">Bu depo artık GitHub'da bulunmuyor.</string>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml`
around lines 866 - 873, The Turkish strings details_refresh_snackbar_archived
and details_refresh_snackbar_not_found contain escaped backslashes before
apostrophes (GitHub\'da) which render literally; edit those entries to remove
the backslashes so they read GitHub'da (plain apostrophe) instead of GitHub\'da,
leaving all other strings unchanged.

Comment on lines +535 to +541
.onPreviewKeyEvent { event ->
if (event.type == KeyEventType.KeyDown &&
(event.isMetaPressed || event.isCtrlPressed) &&
event.key == Key.R
) {
onAction(DetailsAction.Refresh)
true
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 | ⚡ Quick win

Prevent duplicate refresh dispatch while a refresh is already in progress.

Line 540 and Line 557 send DetailsAction.Refresh unconditionally. Unlike the overflow menu, these paths ignore state.isRefreshing, so users can enqueue overlapping refreshes via Ctrl/Cmd+R or pull-to-refresh.

💡 Suggested fix
                 val pullEnabled = remember { isPullToRefreshSupported() }
+                val canTriggerRefresh = !state.isRefreshing

                 ScrollbarContainer(
@@
                             .onPreviewKeyEvent { event ->
                                 if (event.type == KeyEventType.KeyDown &&
                                     (event.isMetaPressed || event.isCtrlPressed) &&
-                                    event.key == Key.R
+                                    event.key == Key.R &&
+                                    canTriggerRefresh
                                 ) {
                                     onAction(DetailsAction.Refresh)
                                     true
@@
                     PullToRefreshHost(
                         enabled = pullEnabled,
                         isRefreshing = state.isRefreshing,
-                        onRefresh = { onAction(DetailsAction.Refresh) },
+                        onRefresh = {
+                            if (canTriggerRefresh) onAction(DetailsAction.Refresh)
+                        },
                     ) {

Also applies to: 554-558

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt`
around lines 535 - 541, The handlers that call onAction(DetailsAction.Refresh)
(notably the onPreviewKeyEvent Ctrl/Cmd+R branch and the pull-to-refresh
callback) must guard against dispatching while a refresh is already running:
check state.isRefreshing and only call onAction(DetailsAction.Refresh) when
false, returning/consuming the event accordingly; update the onPreviewKeyEvent
lambda and the pull-to-refresh refresh handler to first test state.isRefreshing
and skip/return true (or no-op) if a refresh is in progress.

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: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml`:
- Line 868: The Hindi cooldown string details_refresh_cooldown currently renders
as "रीफ़्रेश (%1$dसे)" without spacing; update the string value for
details_refresh_cooldown to include a separator and preserve the %1$d
placeholder — e.g., change to "रीफ़्रेश (%1$d से)" or use the full unit
"रीफ़्रेश (%1$d सेकंड)". Ensure the placeholder stays exactly "%1$d" and
spacing/word choice reads naturally in Hindi.

In
`@feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt`:
- Around line 212-216: The current flow writes the new repo cache via
cacheManager.put but immediately invalidates dependent keys
(cacheManager.invalidate for "details:repo_id:${result.id}",
"details:stats:$owner/$name", "details:latest_release:$owner/$name",
"details:releases:$owner/$name") before the VM refresh repopulates those
dependent payloads; change the flow so dependent caches are only evicted after
their replacements are persisted (or return the refreshed dependent payloads
from the same refresh call). Concretely: perform the follow-up fetches (or
obtain the refreshed stats/releases) and persist them with cacheManager.put
first, then call cacheManager.invalidate for the dependent keys (or skip
invalidate if you replaced them atomically), and adjust
DetailsRepositoryImpl::refresh / DetailsViewModel.refresh to return or await the
refreshed dependent data so eviction happens only after successful writes.

In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt`:
- Around line 2660-2695: The refresh path updates selectedRelease but doesn't
update selectedReleaseCategory, causing UI/filter desync; update
selectedReleaseCategory whenever you set selectedRelease in the refresh flow by
resolving the category from the chosen selectedRelease (re-use the same logic
used in retryReleases() that maps a Release -> selectedReleaseCategory), e.g.
compute newCategory from the selectedRelease (or fallback logic used there) and
set selectedReleaseCategory = newCategory inside the same _state.update block
where selectedRelease is assigned.
🪄 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: f7092855-45fd-441e-a1b6-14f83de879a9

📥 Commits

Reviewing files that changed from the base of the PR and between bb75e6b and cca4290.

📒 Files selected for processing (38)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/network/BackendApiClient.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/RefreshException.kt
  • core/presentation/src/androidMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.android.kt
  • core/presentation/src/commonMain/composeResources/files/whatsnew/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/16.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/16.json
  • 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
  • core/presentation/src/commonMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.kt
  • core/presentation/src/jvmMain/kotlin/zed/rainxch/core/presentation/utils/isPullToRefreshSupported.jvm.kt
  • feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt
  • feature/details/domain/src/commonMain/kotlin/zed/rainxch/details/domain/repository/DetailsRepository.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsEvent.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Comment thread core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml Outdated
Comment on lines +212 to +216
cacheManager.put(cacheKey, result, REPO_DETAILS)
cacheManager.invalidate("details:repo_id:${result.id}")
cacheManager.invalidate("details:stats:$owner/$name")
cacheManager.invalidate("details:latest_release:$owner/$name")
cacheManager.invalidate("details:releases:$owner/$name")
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 | 🏗️ Heavy lift

Avoid evicting dependent caches before the replacements exist.

These invalidations run before DetailsViewModel.refresh() has successfully repopulated releases/stats. If either follow-up fetch fails, the screen keeps stale in-memory values for the current session, but the last-known-good persisted cache is gone for the next load/offline fallback. Consider delaying these invalidations until the replacement payloads have been stored, or returning the dependent refreshed data from the same refresh call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/details/data/src/commonMain/kotlin/zed/rainxch/details/data/repository/DetailsRepositoryImpl.kt`
around lines 212 - 216, The current flow writes the new repo cache via
cacheManager.put but immediately invalidates dependent keys
(cacheManager.invalidate for "details:repo_id:${result.id}",
"details:stats:$owner/$name", "details:latest_release:$owner/$name",
"details:releases:$owner/$name") before the VM refresh repopulates those
dependent payloads; change the flow so dependent caches are only evicted after
their replacements are persisted (or return the refreshed dependent payloads
from the same refresh call). Concretely: perform the follow-up fetches (or
obtain the refreshed stats/releases) and persist them with cacheManager.put
first, then call cacheManager.invalidate for the dependent keys (or skip
invalidate if you replaced them atomically), and adjust
DetailsRepositoryImpl::refresh / DetailsViewModel.refresh to return or await the
refreshed dependent data so eviction happens only after successful writes.

@rainxchzed rainxchzed merged commit 1294ba2 into main May 4, 2026
1 check passed
@rainxchzed rainxchzed deleted the feat/details-manual-refresh branch May 4, 2026 16:25
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